mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
feat(outputch): 提示词显式声明「必须调 outputch 才送达」+ 契约值改名 Agent
## ① 提示词:只有显式调用 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 失败
This commit is contained in:
@ -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) {
|
||||
|
||||
@ -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 的**主动调用**:
|
||||
// 收到一次输入后,可以往**任意(已授权的)通道**发**任意多次**(分段播报、
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 判断鉴权取值是否合法。
|
||||
|
||||
6
third_party/homeagent-sdk/meta/meta.go
vendored
6
third_party/homeagent-sdk/meta/meta.go
vendored
@ -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。
|
||||
//
|
||||
|
||||
43
third_party/homeagent-sdk/sdk/proxy.go
vendored
43
third_party/homeagent-sdk/sdk/proxy.go
vendored
@ -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
|
||||
}
|
||||
|
||||
25
third_party/homeagent-sdk/sdk/proxy_test.go
vendored
25
third_party/homeagent-sdk/sdk/proxy_test.go
vendored
@ -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 决定安全性)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user