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 决定安全性) }