Files
HomeAgent/internal/agent/core/output_rules_test.go
JianFeeeee b20e32d81d 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 失败
2026-10-22 21:17:05 +08:00

132 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"strings"
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
)
// 设计口径:输出是 agent 的**主动调用** —— 收到一次输入后,可以往任意(已授权的)
// 通道发**任意多次**(分段播报、先回执后结论、同时通知多个通道都合法)。
//
// 这条判据钉住的是"提示词里不得出现输出次数限制"。此前 `tooldefs.go` 里写着
// 「每轮对话通常只需调用一次 output_send__{通道名} 即可完成回复」—— 一条凭空的限制,
// 会让模型自己收起合理的多次输出(用户现场指出)。
func TestSystemPromptDoesNotRestrictOutputCount(t *testing.T) {
parent, _, _ := newRootForResidents(t)
defer parent.Stop()
prompt := parent.buildSystemPrompt("", "你好")
banned := []string{
"只需调用一次",
"只能调用一次",
"通常只需调用",
"不要重复发送",
}
for _, b := range banned {
if strings.Contains(prompt, b) {
t.Fatalf("系统提示词里仍有输出次数限制 %q —— 设计上次数不限", b)
}
}
if !strings.Contains(prompt, "输出次数与目标通道由你自己决定") {
t.Fatal("系统提示词应明确「输出次数与目标通道由你自己决定」")
}
if !strings.Contains(prompt, "没有任何「一轮只能发一次」的限制") {
t.Fatal("系统提示词应显式否认「一轮只能发一次」")
}
}
// 「必须显式调用 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) {
parent, _, _ := newRootForResidents(t)
defer parent.Stop()
dev := &outputTestDevice{name: "fakeout"}
if err := parent.io.RegisterDevice(dev); err != nil {
t.Fatal(err)
}
out := parent.executeOutputSendTool(agentAPI.ToolCall{
Name: "output_send__fakeout",
Arguments: map[string]interface{}{"payload": "只给 payload,不给 type"},
})
if out != "ok" {
t.Fatalf("省略 type 时应默认 text 并发送成功,得到 %q", out)
}
if len(dev.sent) != 1 {
t.Fatalf("通道应收到 1 次输出,得到 %d", len(dev.sent))
}
if args, _ := dev.sent[0]["args"].(map[string]interface{}); args["type"] != "text" {
t.Fatalf("缺省类型应为 text,实际 %v", args["type"])
}
// 工具 schema:required 只应含 payload
var found bool
for _, td := range parent.buildToolDefs() {
entry, _ := td.(map[string]interface{})
fn, _ := entry["function"].(map[string]interface{})
if n, _ := fn["name"].(string); n != "output_send__fakeout" {
continue
}
found = true
params, _ := fn["parameters"].(map[string]interface{})
req, _ := params["required"].([]string)
if len(req) != 1 || req[0] != "payload" {
t.Fatalf("output_send 的 required 应只有 payload,实际 %v", req)
}
}
if !found {
t.Fatal("未生成 output_send__fakeout 工具")
}
}
// 空 payload 仍应被拦(这条判据是对的:不知道发什么不能放过)。
func TestOutputSendStillRequiresPayload(t *testing.T) {
parent, _, _ := newRootForResidents(t)
defer parent.Stop()
if err := parent.io.RegisterDevice(&outputTestDevice{name: "fakeout"}); err != nil {
t.Fatal(err)
}
out := parent.executeOutputSendTool(agentAPI.ToolCall{
Name: "output_send__fakeout",
Arguments: map[string]interface{}{"type": "text"},
})
if !strings.Contains(out, "payload 不能为空") {
t.Fatalf("空 payload 应被拦,得到 %q", out)
}
}