From f850d754c1a17cfff567f5e1477aa250041b0e53 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 21:05:17 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(outputch):=20=E4=B8=80=20call=20?= =?UTF-8?q?=E5=A4=9A=E5=8F=91=20+=20=E5=A4=9A=E7=B1=BB=E5=9E=8B=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=86=85=E6=A0=B8=E7=BA=A7=E9=80=9A?= =?UTF-8?q?=E7=94=A8=E8=83=BD=E5=8A=9B=EF=BC=88=E9=9D=9E=E6=9F=90=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E4=B8=93=E5=B1=9E=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户要求:内核 outputch 是通用基础,传入 meta/payload/type(三者均为**插件自定义 字段**)。要支持一次调用发多条、多种类型消息,通过 payload 数组 + type 数组实现。 ## 内核原有形态的限制 output_send__ 每次只能发一条单类型: 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 能力自校验(校验已下放,需要它们真的做) --- internal/agent/core/output.go | 266 +++++++++++++++------ internal/agent/core/outputbatch_test.go | 294 ++++++++++++++++++++++++ internal/agent/core/tooldefs.go | 48 ++-- 3 files changed, 526 insertions(+), 82 deletions(-) create mode 100644 internal/agent/core/outputbatch_test.go diff --git a/internal/agent/core/output.go b/internal/agent/core/output.go index b9c99674..a0f720f4 100644 --- a/internal/agent/core/output.go +++ b/internal/agent/core/output.go @@ -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 { diff --git a/internal/agent/core/outputbatch_test.go b/internal/agent/core/outputbatch_test.go new file mode 100644 index 00000000..9bba758c --- /dev/null +++ b/internal/agent/core/outputbatch_test.go @@ -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) + } +} diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 8f00d14d..662f0467 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -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{}{})) } From b20e32d81d25607dff70494769dbecc48dc1b75e Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 21:17:05 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(outputch):=20=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E6=98=BE=E5=BC=8F=E5=A3=B0=E6=98=8E=E3=80=8C=E5=BF=85?= =?UTF-8?q?=E9=A1=BB=E8=B0=83=20outputch=20=E6=89=8D=E9=80=81=E8=BE=BE?= =?UTF-8?q?=E3=80=8D+=20=E5=A5=91=E7=BA=A6=E5=80=BC=E6=94=B9=E5=90=8D=20Ag?= =?UTF-8?q?ent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ① 提示词:只有显式调用 outputch 才会送达用户(用户明确要求) 模型最常见的错误不是不会调工具,而是**以为返回文本就等于回复了** —— 在异步通道(qq/wechat/群聊)上那等于自言自语,用户永远收不到。 改动前【输出规则】开篇是「消息不会自动发送到对话来源通道,你必须自己决定 如何回复」,语气偏描述。改为开篇总述句: **只有显式调用 `output_send__{通道名}` 才会把内容送达用户。** 返回纯文本只是在这次推理里说话,用户看不到 —— 除非是下面说的同步通道。 同时补上本次内核新增能力:payload 传数组可一次发多条。 ## ② 契约值改名:ProxyAuthHomeAgent="homeagent" → ProxyAuthAgent="agent" 用户指示契约值一起改。常量名与字符串值同时改,但**保留旧值读取路径**: ProxyAuthAgent = "agent" ← 新值 ProxyAuthAgentLegacy = "homeagent" ← 只用于兼容读取 ProxyAuthHomeAgent = ProxyAuthAgent (Deprecated 别名,存量源码可编译) 为什么必须保留:`Auth` 是**写进 plugin.json 的持久化声明值**。存量配置里 写着旧值 —— 本机实测 `/home/newqqagent/plugins/huawei_smarthome/plugin.json:19` 就是 `"auth": "homeagent"`。只认新值 ⇒ 该插件加载时被判为非法声明 ⇒ 反代不生效,症状是「插件好好的但页面打不开」,且无任何报错指向改名。 ### 归一化是安全关键 `EffectiveProxyAuth` 把**空串与旧值都归一化为 ProxyAuthAgent**。 判断点全都写成 `auth == ProxyAuthAgent`。若不归一化,就得每点写两遍比较 —— **漏一处就是鉴权绕过**:旧值被判为「非门户鉴权」⇒ 跳过 authorizeProxy ⇒ 请求直接透传给上游。所以兼容收在一个函数里,不散落在调用点。 ### 顺带修掉两处隐患 1. `webui/proxy.go` 的裸字面量 `"homeagent"`(非法行占位)改为 `sdk.ProxyAuthAgent` —— 裸字面量在改名时会被漏掉,届时它悄悄变成 未知值,而未知值在旧代码里等于「无鉴权」。 2. 两处鉴权判断改用 `EffectiveProxyAuth(...)`,堵住旧值绕过。 ## 判据 | 判据 | 覆盖 | 变异自证 | |---|---|---| | `TestSystemPromptRequiresExplicitOutputCall` | 总述句必须存在、必须说明「返回文本用户看不到」、同步通道是唯一例外、payload 可传数组 | 删掉总述句 ⇒ 红 | | `TestProxyAuthLegacyValueStillAccepted` | 旧值仍被接受 + 必须归一化为新值 | 去掉归一化分支 ⇒ 「旧值应归一化为 "agent",实际 "homeagent"」红 | ## 验证 - `go build ./...` / `go vet ./...` 干净 - webui / internal-sdk / agent-core 全绿 - SDK 仓(独立提交 acd1166):`go build` + `go test ./...` 0 失败 --- internal/agent/core/output_rules_test.go | 31 +++++++++++++++ internal/agent/core/tooldefs.go | 12 ++++-- internal/plugins/webui/proxy.go | 11 ++++-- internal/sdk/proxy.go | 14 ++++++- third_party/homeagent-sdk/meta/meta.go | 6 +-- third_party/homeagent-sdk/sdk/proxy.go | 43 ++++++++++++++++----- third_party/homeagent-sdk/sdk/proxy_test.go | 25 ++++++++++-- 7 files changed, 117 insertions(+), 25 deletions(-) diff --git a/internal/agent/core/output_rules_test.go b/internal/agent/core/output_rules_test.go index 70a6bfe2..2878ff6c 100644 --- a/internal/agent/core/output_rules_test.go +++ b/internal/agent/core/output_rules_test.go @@ -37,6 +37,37 @@ func TestSystemPromptDoesNotRestrictOutputCount(t *testing.T) { } } +// 「必须显式调用 outputch 才会送达用户」必须写在提示词里(用户 2026-10-22 明确要求)。 +// +// 模型最常见的错误不是不会调工具,而是**以为返回文本就等于回复了**—— +// 在异步通道上那等于自言自语,用户永远收不到。 +// +// 变异:删掉这句 ⇒ 红。改成只在「异步通道」那一条里提 ⇒ 红(模型会漏读分项规则)。 +func TestSystemPromptRequiresExplicitOutputCall(t *testing.T) { + parent, _, _ := newRootForResidents(t) + defer parent.Stop() + + prompt := parent.buildSystemPrompt("", "你好") + + // 必须是**开篇总述**级别的一句,不能只藏在分项里。 + if !strings.Contains(prompt, "只有显式调用 `output_send__{通道名}` 才会把内容送达用户") { + t.Fatalf("系统提示词必须以总述句明确「只有显式调用 outputch 才会送达用户」,当前:\n%s", + prompt[:min(600, len(prompt))]) + } + // 且必须点明"返回纯文本用户看不到",否则模型仍会以为直接回答就行。 + if !strings.Contains(prompt, "返回纯文本只是在这次推理里说话,用户看不到") { + t.Error("应显式说明「返回纯文本用户看不到」") + } + // 同步通道是唯一的例外,必须说清,否则模型在 webui 上也会多余地调工具。 + if !strings.Contains(prompt, "这是唯一的例外") { + t.Error("同步通道例外必须显式标为「唯一的例外」") + } + // 批量能力要让模型知道(一 call 多发是本次内核新增能力)。 + if !strings.Contains(prompt, "payload 传数组") { + t.Error("提示词应告知 payload 可传数组一次发多条") + } +} + // 输出工具的 type 可省略,缺省按 text 处理(判据该拦的是"不知道发什么", // 不是"没写众所周知的默认值")。 func TestOutputSendTypeDefaultsToText(t *testing.T) { diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 662f0467..9265940e 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -234,11 +234,17 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string { "- **例外二**:写类工具(记忆/知识/文档写入等)以及不声明**并发安全**的工具,这一批会退回依次执行 —— "+ "只是变慢,不影响结果,你不用为它调整写法。") - prompt = appendPromptBlock(prompt, "【输出规则】消息不会自动发送到对话来源通道,你必须自己决定如何回复:\n") + prompt = appendPromptBlock(prompt, "【输出规则】\n") + // ★ 这一句是本段最重要的:用户明确要求「必须显式调用 outputch 才会输出给用户」。 + // 模型最常见的错误不是不会调工具,而是**以为返回文本就等于回复了**—— + // 异步通道下那等于自言自语(用户永远收不到)。 + prompt += "**只有显式调用 `output_send__{通道名}` 才会把内容送达用户。**" + + "返回纯文本只是在这次推理里说话,用户看不到 —— 除非是下面说的同步通道。\n" prompt += "- **不要假设当前通道是某个固定值**:同一会话里可能同时有多个来源(多设备、多通道、子任务)。\n" prompt += " 先看这条消息本身与上下文里的来源信息,再决定往哪里回;不确定有哪些通道时先调 output_list_channels。\n" - prompt += "- 同步通道(webui / cli / 终端):直接返回纯文本,内核会把文本交给等待方显示,无需调用工具。\n" - prompt += "- 异步通道(qq / wechat / 群聊等):返回纯文本**【不会】**自动送达用户,必须调用 output_send__{通道名} 工具(注意 meta 里带上正确的 user_id 或 group_id)才能真正把消息发出去。\n" + prompt += "- 同步通道(webui / cli / 终端):直接返回纯文本即可,内核会把它交给等待方显示,**这是唯一的例外**。\n" + prompt += "- 异步通道(qq / wechat / 群聊等):**必须**调用 output_send__{通道名},并在 meta 里带上正确的 user_id 或 group_id。\n" + prompt += "- 一次调用可以发多条:payload 传数组即可(type/meta 也支持数组一一对应,只给一个值则应用到每一条)。\n" prompt += "- 不确定当前通道的发送方式时,先用 output_send__{通道名}_help 查看该通道的 meta 格式和 type 枚举,再决定。\n" // ❗这里**不得**限制"一轮只能发一次"。设计上输出是 agent 的**主动调用**: // 收到一次输入后,可以往**任意(已授权的)通道**发**任意多次**(分段播报、 diff --git a/internal/plugins/webui/proxy.go b/internal/plugins/webui/proxy.go index 6eb6c35b..71ee0b50 100644 --- a/internal/plugins/webui/proxy.go +++ b/internal/plugins/webui/proxy.go @@ -589,7 +589,10 @@ func (h *Handler) serveProxyHost(w http.ResponseWriter, r *http.Request) bool { }) return true } - if rt.Auth == sdk.ProxyAuthHomeAgent && !h.authorizeProxy(w, r) { + // 用 EffectiveProxyAuth 而不是直接比常量:存量 plugin.json 里可能写着 + // 旧值(ProxyAuthAgentLegacy)。直接比常量 ⇒ 旧值被判为"非门户鉴权" + // ⇒ **鉴权被绕过**,这是本仓最不能出的错。 + if sdk.EffectiveProxyAuth(rt.Auth) == sdk.ProxyAuthAgent && !h.authorizeProxy(w, r) { return true } if rt.reverse == nil { @@ -622,7 +625,7 @@ func (h *Handler) serveProxyHost(w http.ResponseWriter, r *http.Request) bool { return true } - if route.Auth == sdk.ProxyAuthHomeAgent && !h.authorizeProxy(w, r) { + if sdk.EffectiveProxyAuth(route.Auth) == sdk.ProxyAuthAgent && !h.authorizeProxy(w, r) { return true // 已写 401 } @@ -842,7 +845,9 @@ func parseManualRoutes(text string) []proxyDecl { out = append(out, proxyDecl{ Plugin: "manual", Name: "invalid", Host: "invalid", Target: "", - Auth: "homeagent", + // 非法行占位:用默认安全值(不是裸字面量 —— 契约值改名时 + // 裸字面量会被漏掉,届时这里悄悄变成"无鉴权")。 + Auth: sdk.ProxyAuthAgent, }) continue } diff --git a/internal/sdk/proxy.go b/internal/sdk/proxy.go index 9f3dcafa..4d7925e2 100644 --- a/internal/sdk/proxy.go +++ b/internal/sdk/proxy.go @@ -23,14 +23,24 @@ type ProxyDef = pubsdk.ProxyDef // 鉴权与 Host 规范化的常量/函数转发(调用方不必两处 import)。 const ( + // ProxyAuthAgent 表示由内核统一保护(门户会话或 X-API-Key)。 + ProxyAuthAgent = pubsdk.ProxyAuthAgent + ProxyAuthNone = pubsdk.ProxyAuthNone + + // ProxyAuthAgentLegacy 是旧值 "homeagent":存量 plugin.json 里写的仍是它。 + // 只用于兼容读取;判断时一律先过 EffectiveProxyAuth 归一化。 + ProxyAuthAgentLegacy = pubsdk.ProxyAuthAgentLegacy + + // ProxyAuthHomeAgent 是 ProxyAuthAgent 的旧名,仅为存量源码保留。 + // + // Deprecated: 用 ProxyAuthAgent。 ProxyAuthHomeAgent = pubsdk.ProxyAuthHomeAgent - ProxyAuthNone = pubsdk.ProxyAuthNone ) // ValidateProxyDef 校验一条声明,返回人类可读的错误(合法时为空)。 func ValidateProxyDef(d ProxyDef) string { return pubsdk.ValidateProxyDef(d) } -// EffectiveProxyAuth 返回生效的鉴权模式(空串按默认 homeagent 处理)。 +// EffectiveProxyAuth 返回生效的鉴权模式(空串与旧值都归一化为 ProxyAuthAgent)。 func EffectiveProxyAuth(auth string) string { return pubsdk.EffectiveProxyAuth(auth) } // ValidProxyAuth 判断鉴权取值是否合法。 diff --git a/third_party/homeagent-sdk/meta/meta.go b/third_party/homeagent-sdk/meta/meta.go index c30092ae..70a9b30f 100644 --- a/third_party/homeagent-sdk/meta/meta.go +++ b/third_party/homeagent-sdk/meta/meta.go @@ -1,9 +1,9 @@ -// Package meta 收集 HomeAgent SDK 的全部元数据。 +// Package meta 收集 HologramAgent SDK 的全部元数据。 // 版本号应与核心 meta.Version 保持一致。 package meta var ( - // Version 是 HomeAgent SDK 版本号。 + // Version 是 HologramAgent SDK 版本号。 // 通过 `-ldflags="-X github.com/JianFeeeee/homeagentsdk/meta.Version=vX.Y.Z"` 注入。 // // 版本号语义:**SDK 版本跟随核心的中版本,patch 位恒为 .0**。 @@ -57,7 +57,7 @@ var ( BuildTime = "unknown" // SDKName 是 SDK 名称。 - SDKName = "HomeAgent SDK" + SDKName = "HologramAgent SDK" // SDKModule 是本 SDK 仓当前的 Go module path。 // diff --git a/third_party/homeagent-sdk/sdk/proxy.go b/third_party/homeagent-sdk/sdk/proxy.go index 4db65d3f..9372c970 100644 --- a/third_party/homeagent-sdk/sdk/proxy.go +++ b/third_party/homeagent-sdk/sdk/proxy.go @@ -141,34 +141,57 @@ type ProxyDef struct { Auth string `json:"auth,omitempty"` } -// ProxyAuth 取值。空串按 ProxyAuthHomeAgent 处理(安全的默认)。 +// ProxyAuth 取值。空串按 ProxyAuthAgent 处理(安全的默认)。 const ( - // ProxyAuthHomeAgent 表示由 HomeAgent 统一保护:浏览器走门户会话 - // (homeagent_session cookie),非浏览器客户端走 X-API-Key。 + // ProxyAuthAgent 表示由内核统一保护:浏览器走门户会话 + // (HologramAgent session cookie),非浏览器客户端走 X-API-Key。 // 两者都没有时返回 401,而不是把请求透传给上游。 - ProxyAuthHomeAgent = "homeagent" + ProxyAuthAgent = "agent" - // ProxyAuthNone 表示不经 HomeAgent 鉴权,直接把请求转发给上游。 + // ProxyAuthNone 表示不经内核鉴权,直接把请求转发给上游。 // // 适用场景:上游自己有鉴权且调用方不是浏览器(设备/嵌入式客户端), // 或上游是刻意公开的服务。选用它意味着**信任上游自身的鉴权**, // 且该服务在网络层可达范围内对所有人开放。 ProxyAuthNone = "none" + + // ProxyAuthAgentLegacy 是旧值的字面量,**只用于兼容读取**,不要在新声明里写它。 + // + // 为什么必须继续接受(2026-10-22):`Auth` 是写进 plugin.json 的**持久化 + // 声明值**,存量插件的配置文件里已经写着旧值(本机实测 huawei_smarthome 的 + // plugin.json:19 就是 "auth": "homeagent")。删掉这个分支 ⇒ 那些插件在加载 + // 时被判为非法声明,反代直接不生效,且症状是"插件好好的但页面打不开"。 + // + // 这正是 sdkModuleNames 处理 module 路径改名时的同一条教训:**协议值的 + // 改名必须保留旧值的读取路径**,否则存量数据无声失效。 + ProxyAuthAgentLegacy = "homeagent" ) -// ValidProxyAuth 校验 Auth 取值;空串合法(等价 ProxyAuthHomeAgent)。 +// ProxyAuthHomeAgent 是 ProxyAuthAgent 的旧名,仅为**存量源码**保留。 +// +// Deprecated: 用 ProxyAuthAgent。存量插件的 plugin.json 里写的旧值仍被 +// ValidProxyAuth/EffectiveProxyAuth 接受(见 ProxyAuthAgentLegacy)。 +const ProxyAuthHomeAgent = ProxyAuthAgent + +// ValidProxyAuth 校验 Auth 取值;空串合法(等价 ProxyAuthAgent)。 +// +// 同时接受旧值 ProxyAuthAgentLegacy —— 存量 plugin.json 里写的仍是它。 func ValidProxyAuth(auth string) bool { switch auth { - case "", ProxyAuthHomeAgent, ProxyAuthNone: + case "", ProxyAuthAgent, ProxyAuthNone, ProxyAuthAgentLegacy: return true } return false } -// EffectiveProxyAuth 返回生效的鉴权模式(空串归一化为 ProxyAuthHomeAgent)。 +// EffectiveProxyAuth 返回生效的鉴权模式(空串与旧值都归一化为 ProxyAuthAgent)。 +// +// 归一化旧值是关键:下游一律只可能拿到 ProxyAuthAgent,不必各自兼容旧值 +// (否则每个判断点都要写 `== ProxyAuthAgent || == ProxyAuthAgentLegacy`, +// 漏一处就是鉴权绕过)。 func EffectiveProxyAuth(auth string) string { - if auth == "" { - return ProxyAuthHomeAgent + if auth == "" || auth == ProxyAuthAgentLegacy { + return ProxyAuthAgent } return auth } diff --git a/third_party/homeagent-sdk/sdk/proxy_test.go b/third_party/homeagent-sdk/sdk/proxy_test.go index 1836fc27..f86bfe59 100644 --- a/third_party/homeagent-sdk/sdk/proxy_test.go +++ b/third_party/homeagent-sdk/sdk/proxy_test.go @@ -6,16 +6,33 @@ import ( "testing" ) +// 存量 plugin.json 里写的是旧值 "homeagent"(本机实测 huawei_smarthome)。 +// 改名后必须**继续接受并归一化**它 —— 否则那些插件在加载时被判为非法声明, +// 反代直接不生效,症状是"插件好好的但页面打不开",且没有任何报错指向改名。 +func TestProxyAuthLegacyValueStillAccepted(t *testing.T) { + if !ValidProxyAuth(ProxyAuthAgentLegacy) { + t.Fatalf("旧值 %q 必须仍被接受", ProxyAuthAgentLegacy) + } + if ProxyAuthAgentLegacy == ProxyAuthAgent { + t.Fatal("旧值应与新值不同,否则本判据测不到东西") + } + // ★ 归一化是最关键的一步:下游一律只可能拿到新值, + // 否则每个判断点都要写两遍比较,漏一处就是鉴权绕过。 + if got := EffectiveProxyAuth(ProxyAuthAgentLegacy); got != ProxyAuthAgent { + t.Fatalf("旧值应归一化为 %q,实际 %q", ProxyAuthAgent, got) + } +} + func TestProxyAuthDefaultsToHomeAgent(t *testing.T) { // 空串必须归一化为「HomeAgent 统一保护」——这是安全默认。 // 若哪天有人把默认改成 none,这条会立刻红。 - if got := EffectiveProxyAuth(""); got != ProxyAuthHomeAgent { - t.Fatalf("空 auth 应归一化为 %q,实际 %q", ProxyAuthHomeAgent, got) + if got := EffectiveProxyAuth(""); got != ProxyAuthAgent { + t.Fatalf("空 auth 应归一化为 %q,实际 %q", ProxyAuthAgent, got) } if got := EffectiveProxyAuth(ProxyAuthNone); got != ProxyAuthNone { t.Fatalf("显式 none 应保持 none,实际 %q", got) } - for _, ok := range []string{"", ProxyAuthHomeAgent, ProxyAuthNone} { + for _, ok := range []string{"", ProxyAuthAgent, ProxyAuthNone} { if !ValidProxyAuth(ok) { t.Errorf("%q 应合法", ok) } @@ -86,7 +103,7 @@ func TestValidateProxyDef(t *testing.T) { {Target: "http://127.0.0.1:12100"}, {Target: "127.0.0.1:12100", Host: "huawei"}, {Target: "127.0.0.1:12100", Auth: ProxyAuthNone}, - {Target: "127.0.0.1:12100", Auth: ProxyAuthHomeAgent, WebSocket: true}, + {Target: "127.0.0.1:12100", Auth: ProxyAuthAgent, WebSocket: true}, {Target: "127.0.0.1:3000/base", Host: "x"}, {Target: "https://example.com", Host: "ext"}, // 远程上游也允许(由 auth 决定安全性) } From 55dfa959aac179a0d907274c3abb1c51a60cddb0 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 21:40:48 +0800 Subject: [PATCH 3/4] =?UTF-8?q?ci:=20=E8=A7=A6=E5=8F=91=E9=87=8D=E8=B7=91?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E4=B8=8A=E4=B8=80=E8=BD=AE=20C=20infra?= =?UTF-8?q?structure=20gates=20=E5=9B=A0=20runner=20=E5=8D=A1=E5=9C=A8=20a?= =?UTF-8?q?pt-get=20=E8=B6=85=E6=97=B6=EF=BC=88=E6=9C=AC=E5=9C=B0=E5=90=8C?= =?UTF-8?q?=E9=97=A8=E7=A6=81=E5=85=A8=E9=80=9A=E8=BF=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From d3590192e7d94d0d70e184aa7428c14439cd6129 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 22:42:02 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(outputch):=20=E5=86=85=E6=A0=B8?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=9B=BF=E6=8F=92=E4=BB=B6=E5=88=A4=E6=96=AD?= =?UTF-8?q?=E8=BD=BD=E8=8D=B7=E8=AF=AD=E4=B9=89=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E7=A9=BA=20payload=20=E6=98=AF=E5=90=88=E6=B3=95=E5=8D=A0?= =?UTF-8?q?=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 实测踩到的真 bug(生产环境) QQ 插件重装后首次实测 `sticker_index` 用法,直接失败: output_send__qq result: 参数有误,未发送任何内容:payload 不能为空 而插件侧明明有这个放行分支: if payload == "" && !(rawType == "sticker" && stickerIndex > 0) { return nil, fmt.Errorf("payload 不能为空") } 分支永远走不到 —— 内核先拒了。 ## 根因:两处内核越权判断 | 位置 | 旧判断 | 问题 | |---|---|---| | `parseOutputItems` | `strings.TrimSpace(p) == ""` ⇒ 拒绝 | 内核不知道 payload 语义。`type=sticker` + `meta.sticker_index` 要求 payload 留空占位,真实 URL 由插件从收藏表情取 | | `sendOneOutput` | `stageCtx.FinalText == ""` ⇒ "输出被插件清空" | 原本就空的占位被判成异常 | **正确边界**:参数**是否存在**归内核(没传 `payload` 键 ⇒ 拒绝,结构性错误内核能判);参数**值是否合法**归插件(空串 ⇒ 交插件判断并返回 error)。 这与本轮 outputch 的另一条改动是同一条原则(`type` 的能力校验下放插件): **内核只透传与对齐,不解释插件自定义字段的含义**。 ## 连带修正:`stringOrArray("")` 不再返回 nil 原来 `payload: ""` 被当成"未提供",与"没传 payload 键"混为一谈。 现在空串**保留为一个元素**:前者是模型明确给的空占位(须走到插件), 后者才是真的没提供(由 `len(payloads) == 0` 拦下)。 ## 判据调整(原有判据与正确设计冲突) `TestOutputSend_RejectsEmptyEntries` 断言"含空条目必须整 call 拒绝" —— 那是 按旧的越权设计写的,**与正确边界冲突**。改写为三条分别钉住边界: | 判据 | 断言 | |---|---| | `EmptyPayloadDeferredToPlugin` | 内核不拦空条目,交插件判断(2 条都要送到) | | `EmptyStringIsNotMissing` | `payload: ""` 是占位而非缺失,必须走到插件且原样透传空串 | | `MissingPayloadStillRejected` | 完全没传 payload 键仍拒绝(内核能判的结构性错误) | 变异验证(把 trim 判空加回 `parseOutputItems`)⇒ 两条同时变红: - 「内核不该拦空条目,应交插件判断,实际只发 0 条」 - 「空字符串 payload 应交给插件(sticker_index 用法依赖它),实际 0 次调用」 ## 验证 - `go test ./internal/agent/core/` 全绿(含新 3 条) - 生产实测修复前后对照(journalctl 原文): 21:54:35 output_send__qq result: 参数有误,未发送任何内容:payload 不能为空 ← 修复前 21:56:34 output_send__qq result: ok ← 修复后 - webui / agent-core 全绿 --- internal/agent/core/output.go | 31 ++++++++++---- internal/agent/core/outputbatch_test.go | 56 ++++++++++++++++++++++--- 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/internal/agent/core/output.go b/internal/agent/core/output.go index a0f720f4..3dcdf629 100644 --- a/internal/agent/core/output.go +++ b/internal/agent/core/output.go @@ -68,11 +68,17 @@ func parseOutputItems(args map[string]interface{}) ([]outputItem, error) { 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) - } - } + // ★ 空字符串元素**不在这里拒绝**(2026-10-22 实测踩到)。 + // + // 内核不知道 payload 的语义:它可能是"要发的内容",也可能是占位 —— + // 例如 QQ 的 `type=sticker` + `meta.sticker_index=1`,真实 URL 由插件从 + // 收藏表情里取,模型根本不知道该往 payload 填什么。内核在这里判空, + // 等于用一个它不理解的字段把插件的合法用法挡掉(实测报 + // "payload 不能为空",而插件侧的放行分支永远走不到)。 + // + // 正确边界:**参数是否存在归内核管,参数值是否合法归插件管**。 + // 所以只拒绝"整条数组里没有任何一项"这种结构性错误(上面那条), + // 逐项内容交给插件判断并返回 error。 // type 对齐:省略 / 单值 ⇒ 广播;否则必须与 payload 等长 if len(types) == 0 { @@ -125,9 +131,12 @@ func parseOutputItems(args map[string]interface{}) ([]outputItem, error) { func stringOrArray(v interface{}) []string { switch t := v.(type) { case string: - if t == "" { - return nil - } + // 空字符串**保留**为一个元素,不当"未提供"处理。 + // + // 区别很重要:`payload: ""` 是"模型明确给了一个空载荷占位" + // (插件可能从 meta 里取真实内容,如 QQ 的 sticker_index), + // 而"没传 payload 这个键"才是真的没提供。前者归插件判断合法性, + // 后者由 parseOutputItems 的 len==0 拦下。 return []string{t} case []string: return t @@ -246,7 +255,11 @@ func (a *Agent) sendOneOutput(channel string, item outputItem) (string, bool) { if stageCtx.Response != nil { return fmt.Sprintf("输出被插件拦截: %s", *stageCtx.Response), false } - if stageCtx.FinalText == "" { + // 只有"原本非空、被插件清空"才是异常(防插件静默吞掉内容却仍算成功)。 + // + // 原本就空 ⇒ 合法占位(如 type=sticker + meta.sticker_index,真实 URL + // 由插件自己取),不能报"被清空"——那是拿内核不理解的字段做判断。 + if item.payload != "" && stageCtx.FinalText == "" { return "输出被插件清空", false } args["payload"] = stageCtx.FinalText diff --git a/internal/agent/core/outputbatch_test.go b/internal/agent/core/outputbatch_test.go index 9bba758c..d1fbfb5e 100644 --- a/internal/agent/core/outputbatch_test.go +++ b/internal/agent/core/outputbatch_test.go @@ -260,19 +260,65 @@ func TestOutputSend_KernelDoesNotInterpretType(t *testing.T) { } } -// ⑦ 空 payload 与非字符串元素必须被拒(不能静默发空消息)。 -func TestOutputSend_RejectsEmptyEntries(t *testing.T) { +// ⑦ 空 payload 交给插件判断,内核不拦(★ 实测踩到的真实缺陷)。 +// +// 原先这里是"含空条目必须整 call 拒绝"。但内核**不知道 payload 的语义**: +// QQ 的 `type=sticker` + `meta.sticker_index=1` 需要 payload 留空占位, +// 真实 URL 由插件从收藏表情里取。旧实现在内核判空 ⇒ 插件侧的放行分支 +// 永远走不到 ⇒ 生产实测报 "payload 不能为空",功能完全不可用。 +// +// 正确边界:**参数是否存在归内核,参数值是否合法归插件**。 +// +// 变异:把 trim 判空加回去 ⇒ 红(内核又替插件做决定了)。 +func TestOutputSend_EmptyPayloadDeferredToPlugin(t *testing.T) { ch := newRecordingChannel() a := newOutputTestAgent(t, ch, agentIO.CapText) out := a.executeOutputSendTool(sendArgs(map[string]interface{}{ "payload": []interface{}{"正常", " "}, + "meta": `{"sticker_index":1}`, + })) + // 内核应把两条都交给插件(内容合法性由插件判) + if len(ch.calls) != 2 { + t.Errorf("内核不该拦空条目,应交插件判断,实际只发 %d 条(返回 %q)", len(ch.calls), out) + } +} + +// ⑦b 单个空 payload 是"占位",不是"没传"。 +// +// 区别:`payload: ""` 说明模型明确给了空占位;"没传 payload 键"才是真没提供。 +// 前者必须走到插件(否则 sticker_index 用法永远不可用)。 +func TestOutputSend_EmptyStringIsNotMissing(t *testing.T) { + ch := newRecordingChannel() + a := newOutputTestAgent(t, ch, agentIO.CapText) + + out := a.executeOutputSendTool(sendArgs(map[string]interface{}{ + "payload": "", + "type": "sticker", + "meta": `{"user_id":1,"sticker_index":1}`, + })) + if len(ch.calls) != 1 { + t.Fatalf("空字符串 payload 应交给插件(sticker_index 用法依赖它),实际 %d 次调用(返回 %q)", + len(ch.calls), out) + } + if p, _ := ch.calls[0]["payload"].(string); p != "" { + t.Errorf("payload 应原样透传空串,实际 %q", p) + } +} + +// ⑦c 但"完全没传 payload 键"仍必须拒绝(结构性错误,内核能判)。 +func TestOutputSend_MissingPayloadStillRejected(t *testing.T) { + ch := newRecordingChannel() + a := newOutputTestAgent(t, ch, agentIO.CapText) + + out := a.executeOutputSendTool(sendArgs(map[string]interface{}{ + "type": "text", })) if len(ch.calls) != 0 { - t.Errorf("含空条目的批量必须整 call 拒绝,实际发了 %d 条", len(ch.calls)) + t.Errorf("完全没传 payload 必须拒绝,实际发了 %d 条", len(ch.calls)) } - if !strings.Contains(out, "为空") { - t.Errorf("返回值应指出空条目,实际: %q", out) + if !strings.Contains(out, "payload 不能为空") { + t.Errorf("返回值应说明 payload 不能为空,实际 %q", out) } }