3 Commits

Author SHA1 Message Date
fddefc78a1 fix(plugin): "只声明出站通道"的告警改为插件加载完成后判定(此前按注册顺序误报 qq)
## 现象

生产日志(v1.3.1 启动)出现:
`[plugin] qq 只声明了输出通道 "qq",已按双向通道兜底登记 inputch;若要明确意图请显式 RegisterInputChannel`
用户据此问"qq 插件你没更新?"

## 查证:qq 没漏,是我的判据错了

- SDK 示例 `example/qq/plugin.go`:`RegisterOutputChannel("qq")` 在 368 行、
  `RegisterInputChannel("qq", {NoMemory:true, Cleaner: inputCleaner})` 在 399 行 —— **先出站后入站**;
- 生产 `plugins/qq/plugin.bin`:版本 1.4.0,且二进制里含 `inputCleaner` 痕迹 ⇒ 确实调用了入站声明;
- 我的兜底告警在 **RegisterOutputChannel 的那一刻**判"有没有入站声明" ⇒ 对"先出站后入站"
  这种完全合法的写法必然误报(a2a/acp/weather 同理)。

## 修法

告警判据从"注册时刻"改为"**插件 Start 结束后最终声明了什么**":

- `regOutput` 只保留兜底登记(功能不变),不再告警;
- 新增 `warnOutputOnlyChannels(plugin)`,在插件加载/重载成功后统一判定:
  遍历该插件**最终**声明过的出站通道,只有始终没有对应入站声明的才告警,
  且措辞改为"内核已兜底登记 inputch,若这是有意为之可忽略"。
- 判据与顺序解耦后,告警才代表真实缺口(例:weather 的 `weather_out` 与
  `weather_in` 名字不同,出站名从未被声明为入站 —— 那条告警就是真的)。

## 验证

- 新增 `TestWarnOutputOnlyChannels`:①先出站后入站(qq 写法)**不告警**;
  ②只声明出站(weather 写法)**告警且只报那一个通道**。
- `go test ./internal/plugin/ ./internal/plugins/...` 全绿。
2026-09-13 13:40:24 +08:00
3f431063e2 chore(sdk-mirror): 同步 SDK 仓 v1.3.1 的文档 —— 通道名会进 LLM 函数名(命名约束)
镜像文件:`third_party/homeagent-sdk/sdk/plugin.go`(`RegisterOutputChannel` 的命名约束)。
SDK 仓对应提交/tag:v1.3.1。

说明:内核 v1.3.1 的 tag 已指向功能修复提交 d17c186,本镜像提交在其之后 ——
文档镜像不参与二进制构建,故不影响已部署产物;功能与文档的对应关系见两仓 tag 说明。
2026-09-13 13:10:38 +08:00
18d7ad3a36 fix(remotedevice): 设备通道名改用 - 分隔并派生合规名(v1.3.0 部署后 agent 完全不应答的根因)
## 事故

v1.3.0 部署到生产后,**整个 agent 不应答**:任何对话都返回
`all 3 providers failed, last error: api error 403: model "claude-opus-5" is not allowed for this key`。
回滚到 1.2.2 立即恢复(部署前 403=0/成功对话=10,部署后 403=5/成功对话=0)。

## 根因(网关日志给出的原文)

```
tier 3 gozen/deepseek-v4.1-flash: api error 400: [invalid_request_error]
  Invalid 'tools[299].function.name': string does not match pattern '^[a-zA...
```

设备的每设备输出通道名叫 `device/<id>`,内核按 `output_send__<通道名>` 生成工具 ⇒
`output_send__device/<id>` 里的 `/` 违反上游函数名规范 `^[a-zA-Z0-9_-]{1,64}$`。
上游不是"拒掉这一个工具",而是**整条请求 400** ⇒ 网关 auto tier 全链条失败
(400/429/503 混在一起)⇒ 内核只能报"所有 provider 都失败"。
两台真实设备(waiter-fnnas / waiter-mainnas)一上线就登记了这种通道,于是必然触发。

## 修法(改插件,不改内核)

初版我在内核里加了"通道名净化 + 反向解析"层。用户否掉了这个方向,理由对:
**通道名是插件自己的声明,不合契约就该改插件**,不该让内核替插件擦屁股。
内核侧改动已全部回退(HEAD 干净)。

插件侧两处:
1. 分隔符 `device/<id>` → `device-<id>`(源码与来源标签统一,不留两套名字)。
2. 设备 id 是**外部输入**(设备自己声明),可能含空格/非 ASCII/超长 ⇒
   `deviceChannelName()` 把它派生为**合规且唯一**的通道名:
   保留 `[A-Za-z0-9_-]`、其它折成 `-`、主体截断到 32 字符(预算 64 = 13+7+32+7+…)、
   发生截断或撞名时追加 id 的 6 位短哈希。同一 id 恒定同名;真名仍用于路由与日志。

核心契约写进了插件注释与 SDK 文档(见 SDK 仓同批提交):名字若来自外部输入,
**在插件侧派生合规名**,内核不会替你净化。

## 验证

- 新增 `TestDeviceChannelNameIsLLMFunctionNameSafe`:恶意 id(空格/符号/非 ASCII/超长/
  会折成同名的两个 id)都必须派生出**合法且互不重复**的通道名与工具名。
  反向验证:把分隔符改回 `/` 即 FAIL。
- 生产两台设备派生结果:`device-waiter-fnnas`、`device-waiter-mainnas`
  ⇒工具名 `output_send__device-waiter-fnnas`(37 字符,合规)。
- 全量 `go test ./...` = 37 包 ok / 0 FAIL;`-race`(remotedevice + core)无 DATA RACE。
2026-09-13 13:10:38 +08:00
7 changed files with 39 additions and 153 deletions

View File

@ -1,32 +0,0 @@
package core
import (
"strings"
"testing"
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
sdkmeta "gitcode.com/JianFeeeee/homeagent-sdk/meta"
)
func TestExpandPromptVars(t *testing.T) {
got := expandPromptVars("型号 {{kernel_version}}{{kernel_commit}}SDK {{sdk_version}}")
if !strings.Contains(got, meta.Version) || !strings.Contains(got, sdkmeta.Version) {
t.Fatalf("占位符未展开: %q", got)
}
if strings.Contains(got, "{{") {
t.Fatalf("仍有未展开的内置占位符: %q", got)
}
// 人格卡实测原文:写死了 v1.0.3,应能被占位符取代
live := expandPromptVars("你是 HomeAgent 的看板娘「小宅」(Xiao Zhai)HΔ-Kernel v{{kernel_version}} 型号的家政型 AI 管家助手。")
if strings.Contains(live, "1.0.3") || !strings.Contains(live, "v"+meta.Version) {
t.Fatalf("人格卡版本未跟随内核: %q", live)
}
// 未知占位符必须原样保留(写错要看得见,不能被静默吞掉)
if unk := expandPromptVars("版本 {{kernel_verison}}"); !strings.Contains(unk, "{{kernel_verison}}") {
t.Fatalf("未知占位符被吞: %q", unk)
}
// 无占位符时原样返回(人格卡热路径,不做无谓拷贝)
if plain := "无占位符"; expandPromptVars(plain) != plain {
t.Fatal("无占位符时不应改写")
}
}

View File

@ -5,8 +5,6 @@ import (
"strings"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
sdkmeta "gitcode.com/JianFeeeee/homeagent-sdk/meta"
)
func (a *Agent) buildMemoryContext(input string, maxTokens int) string {
@ -39,30 +37,8 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int) string {
return s
}
// expandPromptVars 展开自定义提示词(人格卡)里的版本占位符。
//
// 为什么需要:人格卡是**配置项**,一旦写死版本号就会随内核发版而说谎 ——
// 实测线上人格卡写着 "HΔ-Kernel v1.0.3 型号",内核早已 1.3.xagent 向用户
// 自报版本时就照抄 1.0.3。占位符让这类文本永远跟随真实构建:
//
// {{kernel_version}} → 内核版本(如 1.3.5
// {{kernel_commit}} → 构建 commit
// {{sdk_version}} → 所兼容的 SDK 版本(如 1.3.0
//
// 未知占位符**原样保留**:写错了要看得见,而不是被静默换成空串。
func expandPromptVars(s string) string {
if !strings.Contains(s, "{{") {
return s
}
return strings.NewReplacer(
"{{kernel_version}}", meta.Version,
"{{kernel_commit}}", meta.Commit,
"{{sdk_version}}", sdkmeta.Version,
).Replace(s)
}
func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
prompt := expandPromptVars(a.systemPrompt)
prompt := a.systemPrompt
if prompt == "" {
prompt = "你是小宅HomeAgent 的看板娘,一个家政型 AI 管家助手。绝不用 Unicode emoji只用颜文字表达情感句尾带语气词。WebUI 概览页展示你的立绘。"
}

View File

@ -742,7 +742,7 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
reg(ConfigDef{Key: "core.agent.workdir", Default: "", Type: "string", DisplayName: "工作目录", Description: "Agent 命令执行的默认工作目录(如 cmd_run 工具的 fallback留空使用内核所在目录", Category: "agent"})
reg(ConfigDef{Key: "core.agent.embedding_model_path", Default: "", Type: "string", DisplayName: "预训练词嵌入模型路径", Description: "预训练词嵌入模型路径word2vec 文本格式),支持逗号分隔多个模型。路径后可加 #topN 规格只加载前 N 个词向量(如 /data/cc.zh.300.vec#top50000以控制常驻内存词频降序命中覆盖绝大部分文本。空则使用 TF-IDF 回退。修改后需重启生效。", Category: "agent"})
reg(ConfigDef{Key: "core.agent.onnx_model_path", Default: "", Type: "string", DisplayName: "ONNX 模型路径", Description: "依存句法分析 ONNX 模型文件路径。留空使用二进制内嵌模型/规则引擎。修改后需重启生效。", Category: "agent"})
reg(ConfigDef{Key: "core.agent.system_prompt", Default: "", Type: "text", DisplayName: "系统身份提示词", Description: "Agent 的系统提示词,定义身份和行为规则。留空则使用编译时内置默认值。支持版本占位符(随构建实时展开,避免写死版本号随发版说谎):{{kernel_version}}、{{kernel_commit}}、{{sdk_version}}。修改后需重启生效。", Category: "agent"})
reg(ConfigDef{Key: "core.agent.system_prompt", Default: "", Type: "text", DisplayName: "系统身份提示词", Description: "Agent 的系统提示词,定义身份和行为规则。留空则使用编译时内置默认值。修改后需重启生效。", Category: "agent"})
reg(ConfigDef{Key: "core.input_processing.image.fallback_provider", Default: "", Type: "string", DisplayName: "图片回退提供商", Description: "当主 LLM 不支持图片处理时使用的提供商(留空则自动降级为文字描述)", Category: "input"})
reg(ConfigDef{Key: "core.input_processing.image.fallback_model", Default: "", Type: "string", DisplayName: "图片回退模型", Description: "图片回退提供商使用的模型名", Category: "input"})

View File

@ -34,15 +34,11 @@ type StaticEmbedder struct {
jieba *gojieba.Jieba
stopWords map[string]bool
// words 是词向量表。**用 float32 存**源文件fastText 文本格式)本身就是 float32
// 用 float64 存等于把 578 万……不,是 57.8 万词 × 300 维的常驻内存凭空翻倍
// 实测生产float64 → 1.29GBfloat32 → 0.65GB)。相似度计算仍在 float64 里累加,
// 精度不受影响。改回 float64 会被 TestStaticEmbedder_VectorMemIsFloat32 拦住。
words map[string][]float32
words map[string][]float64
dim int
loaded bool
unkVec []float32
unkVec []float64
unkNorm float64
}
@ -154,7 +150,7 @@ func NewStaticEmbedder(modelPaths ...string) *StaticEmbedder {
e := &StaticEmbedder{
jieba: GetJieba(),
stopWords: sw,
words: make(map[string][]float32),
words: make(map[string][]float64),
}
if len(modelPaths) == 0 {
@ -258,16 +254,15 @@ func (e *StaticEmbedder) load(spec string, primary bool) error {
continue
}
vec := make([]float32, dim)
vec := make([]float64, dim)
for i := 0; i < dim; i++ {
// 源文件是 float32 精度的文本向量:用 32 位解析,与源数据一致。
v, _ := strconv.ParseFloat(fields[i+1], 32)
vec[i] = float32(v)
v, _ := strconv.ParseFloat(fields[i+1], 64)
vec[i] = v
}
e.words[word] = vec
if primary {
for i := range vecSum {
vecSum[i] += float64(vec[i])
vecSum[i] += vec[i]
}
count++
}
@ -281,13 +276,11 @@ func (e *StaticEmbedder) load(spec string, primary bool) error {
for i := range vecSum {
vecSum[i] /= float64(count)
}
e.unkVec = make([]float32, dim)
for i, v := range vecSum {
e.unkVec[i] = float32(v)
}
e.unkVec = make([]float64, dim)
copy(e.unkVec, vecSum)
var normSq float64
for _, v := range e.unkVec {
normSq += float64(v) * float64(v)
normSq += v * v
}
e.unkNorm = float64(math.Sqrt(normSq))
e.loaded = true
@ -373,11 +366,11 @@ func (e *StaticEmbedder) Vectorize(text string) vector.Vector {
if !ok {
for i, v := range unkVec {
sum[i] += w * float64(v)
sum[i] += w * v
}
} else {
for i, v := range vec {
sum[i] += w * float64(v)
sum[i] += w * v
}
}
weightSum += w

View File

@ -1,37 +0,0 @@
package memory
import (
"testing"
"unsafe"
)
// 词向量必须用 float32 存。
//
// 这条判据是拿生产内存换来的:向量本体 = 词数 × 维数 × 每元素字节数。
// 生产配置加载了 200000(zh) + 378151(en) = 57.8 万词 × 300 维 ⇒
// float64 = 1.29GB、float32 = 0.65GB(差 0.65GB 常驻)。
// 源数据fastText 文本格式)本身就是 float32 精度,用 float64 存没有任何收益。
//
// 若有人把类型改回 float64本测试**编译失败**`var vec []float32` 的类型断言),
// 这正是想要的效果。
func TestStaticEmbedder_VectorMemIsFloat32(t *testing.T) {
e := newSynthEmbedder(t, 300)
words := 0
bytes := 0
for _, vec := range e.words {
var typed []float32 = vec // 编译期断言:存储必须是 []float32
if len(typed) != e.dim {
t.Fatalf("维度不符: %d != %d", len(typed), e.dim)
}
words++
bytes += len(typed) * int(unsafe.Sizeof(typed[0]))
}
if words == 0 {
t.Fatal("合成模型应至少加载一个词")
}
// float32每词 300×4 = 1200 字节float64 会是 2400
if want := words * e.dim * 4; bytes != want {
t.Fatalf("向量本体字节数应 %dfloat32实际 %d", want, bytes)
}
}

View File

@ -28,10 +28,7 @@ var (
//
// ❗main 上此值始终是**下一个未发布中版本**,不随 patch 发布变动
//(见 docs/git-branching.md §2.1);已发布的版本号看对应的 release/vX.Y.x 与 tag。
// 1.3.5:系统提示词(人格卡)支持版本占位符 —— 人格卡是配置项,写死版本号
// 会随发版说谎(线上写 v1.0.3、内核 1.3.xagent 就自报 1.0.3)。
// 支持 {{kernel_version}} / {{kernel_commit}} / {{sdk_version}}。
Version = "1.3.5"
Version = "1.3.0"
// Commit 是构建时的 Git commit hash。
Commit = "unknown"

View File

@ -138,39 +138,28 @@ func (p *Plugin) Stop() error {
func (p *Plugin) registerTools(s *sdk.PluginSDK) {
s.RegisterTool("plugin_install", sdk.ToolDef{
Name: "plugin_install",
Description: "安装 HomeAgent 插件包(.hmap。两种来源urlhttp/https 下载)或 path本机路径配合 plugindev_build 的产物用这个)。插件已存在时传 overwrite=true 原地更新(升级/降级/重装,保留配置表,无需卸载重装)。更新后需调用 plgreload 或重启生效。",
Description: "从 URL 安装 HomeAgent 插件包(.hmap 文件)。插件已存在时传 overwrite=true 原地更新(升级/降级/重装,保留配置表,无需卸载重装)。更新后需调用 plgreload 或重启生效。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{
"type": "string",
"description": "插件包的下载 URLhttp/https",
},
"path": map[string]interface{}{
"type": "string",
"description": "插件包在**本机**的路径(.hmap。与 url 二选一;同时给出时以 path 为准",
"description": "插件包的下载 URL",
},
"overwrite": map[string]interface{}{
"type": "boolean",
"description": "已存在时原地更新(保留配置)。默认 false",
},
},
"required": []string{"url"},
},
}, func(args map[string]interface{}) (interface{}, error) {
overwrite, _ := args["overwrite"].(bool)
// path 优先:它对应"agent 自己构建出产物再装"的场景plugindev_build → plugin_install
if path, _ := args["path"].(string); strings.TrimSpace(path) != "" {
pth := strings.TrimSpace(path)
if st, err := os.Stat(pth); err != nil || st.IsDir() {
return map[string]interface{}{"error": fmt.Sprintf("path 无效(必须是存在的 .hmap 文件): %s", pth)}, nil
}
return p.installFromPath(pth, overwrite)
}
url, _ := args["url"].(string)
if strings.TrimSpace(url) == "" {
return map[string]interface{}{"error": "需要 url 或 path二选一"}, nil
if url == "" {
return map[string]interface{}{"error": "url is required"}, nil
}
return p.installFromURL(strings.TrimSpace(url), overwrite)
overwrite, _ := args["overwrite"].(bool)
return p.installFromURL(url, overwrite)
})
s.RegisterTool("plugin_list", sdk.ToolDef{
@ -466,12 +455,12 @@ func (p *Plugin) installFromData(data []byte, overwrite bool) (interface{}, erro
if existing && !overwrite {
return map[string]interface{}{
"error": "plugin already exists",
"name": pkg.Name,
"version": pkg.Version,
"current": oldVersion,
"action": "remove_first",
"hint": `传 "overwrite": true 可原地更新(保留配置)`,
"error": "plugin already exists",
"name": pkg.Name,
"version": pkg.Version,
"current": oldVersion,
"action": "remove_first",
"hint": `传 "overwrite": true 可原地更新(保留配置)`,
}, nil
}
@ -486,7 +475,7 @@ func (p *Plugin) installFromData(data []byte, overwrite bool) (interface{}, erro
os.RemoveAll(backup)
if err := os.Rename(target, backup); err != nil {
return map[string]interface{}{
"error": "backup old plugin dir failed",
"error": "backup old plugin dir failed",
"details": err.Error(),
}, nil
}
@ -495,8 +484,8 @@ func (p *Plugin) installFromData(data []byte, overwrite bool) (interface{}, erro
os.RemoveAll(target)
if rbErr := os.Rename(backup, target); rbErr != nil {
return map[string]interface{}{
"error": "extract failed AND rollback failed",
"details": err.Error(),
"error": "extract failed AND rollback failed",
"details": err.Error(),
"rollback": rbErr.Error(),
}, nil
}
@ -518,15 +507,15 @@ func (p *Plugin) installFromData(data []byte, overwrite bool) (interface{}, erro
action = "reinstalled"
}
return map[string]interface{}{
"status": "installed",
"name": pkg.Name,
"version": pkg.Version,
"status": "installed",
"name": pkg.Name,
"version": pkg.Version,
"previous_version": oldVersion,
"entry": pkg.Entry,
"checksum": checksum,
"action": action,
"reload_required": true,
"config_kept": true,
"entry": pkg.Entry,
"checksum": checksum,
"action": action,
"reload_required": true,
"config_kept": true,
}, nil
}