7 Commits
main ... v1.3.5

Author SHA1 Message Date
02600a3b90 chore(version): release/v1.3.x 路牌推到 1.3.5(本版内容:系统提示词版本占位符)
发布线的 meta.Version 始终是**该线已发的最后一个 patch**(release/v1.2.x 末态为 1.2.2)。
1.3.1–1.3.4 我是用 -ldflags -X 把版本打进二进制的,文件里的路牌一直停在 1.3.0 ——
源码与产出的版本号对不上账,属于偏离规范,本次一并纠正。
2026-09-13 14:31:14 +08:00
02861f5d64 fix(prompt): 系统提示词支持版本占位符 —— 人格卡不再写死版本号
现象(用户发现):agent 向用户自报版本是 **1.0.3**,而内核早已 1.3.x。
根因:**人格卡是配置项**,线上 `core.agent.system_prompt` 里写死了
「HΔ-Kernel v1.0.3 型号的家政型 AI 管家助手」——那是当年装机的文本,
之后每次发版都不会去改它,模型于是照抄给用户。默认模板用 `meta.Version`
拼接(`registry.go` 的 `fmt.Sprintf`)所以一直是对的,**只要被自定义过就会漂**。

修法:在 `buildSystemPrompt` 组装处展开占位符,让这类文本跟随真实构建:

  {{kernel_version}} → meta.Version(如 1.3.5)
  {{kernel_commit}}  → 构建 commit
  {{sdk_version}}    → 所兼容 SDK 版本(如 1.3.0)

- 未知占位符**原样保留**:写错了要看得见,而不是被静默换成空串;
- 不含 `{{` 时原样返回(提示词在热路径上);
- 覆盖所有路径:主 agent 与驻留子都经 `buildSystemPrompt`,
  `persona_set` 写入的文本同样在读取时展开(存的是模板,不是渲染结果);
- 配置项描述里写明可用占位符,引导用户别再写死版本。

回归判据 `TestExpandPromptVars`:展开正确 / 内置占位符不残留 /
线上真实人格卡文本能被纠正 / 未知占位符不被吞 / 无占位符不改写。
2026-09-13 14:31:13 +08:00
cea8011f3d feat(pluginmgr): plugin_install 支持本机 path(配合 plugindev_build 的产物)
背景:Agent 现在能自己构建插件了(plugindev 插件封装了 hmapdev),但安装只支持
http(s) URL —— 本地刚构建出来的 `dist/*.hmap` 装不上,链路断在最后一步。
pluginmgr 的 HTTP API 本来就接受 `{path}`(installFromPath),只是工具面没暴露。

改动:`plugin_install` 增加可选 `path`(本机 .hmap 路径),与 `url` 二选一,
同时给出时以 `path` 为准;`path` 必须存在且不是目录。描述里写明
「配合 plugindev_build 的产物用这个」。

于是 Agent 的完整闭环成立:
  plugindev_init → plugindev_build → plugin_install(path) → plgreload
2026-09-13 14:17:08 +08:00
68835c18db perf(memory): 静态词向量改用 float32 存储(省 ~0.65GB 常驻)
生产实测:`[static_embedder] loaded 200000 words`(zh) + `378151 words`(en) = 57.8 万词 × 300 维,
`map[string][]float64` 光向量本体就 **1.29GB**(外加 map 开销 ~0.1-0.2GB),占 homed
4.14GB RSS 的约三分之一。

源数据(fastText 文本格式)本身就是 float32 精度,用 float64 存没有任何收益:
- `words map[string][]float32` / `unkVec []float32`;
- 加载时按 `ParseFloat(..., 32)` 解析(与源精度一致);
- 相似度累加仍在 float64(`sum []float64`,读时提升),计算精度不受影响。

⇒ 向量本体 1.29GB → 0.65GB,**省 0.65GB**。(与配置侧 `#topN` 可叠加:
生产把两份 vec 各限 5 万词后,向量降到 ~0.22GB。)

防复发:`TestStaticEmbedder_VectorMemIsFloat32` 用**编译期类型断言**
(`var typed []float32 = vec`)+ 字节数断言(词数×维数×4)钉住 —— 改回 float64 会直接编译失败。

验证:`go test ./internal/memory/ ./internal/agent/core/ ./internal/nlp/` 全绿。
2026-09-13 14:09:31 +08:00
89db544671 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:46 +08:00
a021055011 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:25 +08:00
d17c18665c 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:07:24 +08:00
14 changed files with 356 additions and 62 deletions

View File

@ -0,0 +1,32 @@
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,6 +5,8 @@ import (
"strings" "strings"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" 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 { func (a *Agent) buildMemoryContext(input string, maxTokens int) string {
@ -37,8 +39,30 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int) string {
return s 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 { func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
prompt := a.systemPrompt prompt := expandPromptVars(a.systemPrompt)
if prompt == "" { if prompt == "" {
prompt = "你是小宅HomeAgent 的看板娘,一个家政型 AI 管家助手。绝不用 Unicode emoji只用颜文字表达情感句尾带语气词。WebUI 概览页展示你的立绘。" 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.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.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.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 的系统提示词,定义身份和行为规则。留空则使用编译时内置默认值。修改后需重启生效。", 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.input_processing.image.fallback_provider", Default: "", Type: "string", DisplayName: "图片回退提供商", Description: "当主 LLM 不支持图片处理时使用的提供商(留空则自动降级为文字描述)", Category: "input"}) 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"}) reg(ConfigDef{Key: "core.input_processing.image.fallback_model", Default: "", Type: "string", DisplayName: "图片回退模型", Description: "图片回退提供商使用的模型名", Category: "input"})

View File

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

View File

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

View File

@ -0,0 +1,43 @@
package plugin
import (
"bytes"
"log"
"strings"
"testing"
)
// 延迟判定的语义:看的是"插件 Start 结束后最终声明了什么"
// 而不是"注册出站通道的那一刻有没有入站声明"。
//
// 为什么必须这样判:声明顺序自由 —— qq/weather 都是**先** RegisterOutputChannel
// **后** RegisterInputChannel按注册时刻判会把它们误报成"只声明了输出通道"
// (实测发生过:用户据此以为 qq 插件没更新)。
func TestWarnOutputOnlyChannels(t *testing.T) {
r := NewRegistry()
var buf bytes.Buffer
oldOut := log.Writer()
log.SetOutput(&buf)
defer log.SetOutput(oldOut)
// ① 出站+入站都声明了(先出站后入站)⇒ 不该告警
r.noteChannel("qq", "qq", true)
r.noteChannel("qq", "qq", false)
r.warnOutputOnlyChannels("qq")
if s := buf.String(); s != "" {
t.Fatalf("qq 声明了入站通道,不应告警,实际: %s", s)
}
// ② 只声明出站 ⇒ 应告警,且只报这一个通道
buf.Reset()
r.noteChannel("weather", "weather_weather_out", true)
r.noteChannel("weather", "weather_weather_in", false)
r.warnOutputOnlyChannels("weather")
out := buf.String()
if !strings.Contains(out, "weather_weather_out") {
t.Fatalf("只声明出站的通道应被告警,实际: %q", out)
}
if strings.Contains(out, "weather_weather_in") {
t.Fatalf("已声明入站的通道不该被牵连,实际: %q", out)
}
}

View File

@ -307,12 +307,15 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
// 但历史插件常常只用 RegisterOutputChannel 声明(却用同一个名字注入输入, // 但历史插件常常只用 RegisterOutputChannel 声明(却用同一个名字注入输入,
// 例cli 只声明输出 "cli" 就用 InjectTextSync("cli", ...) 注入)。 // 例cli 只声明输出 "cli" 就用 InjectTextSync("cli", ...) 注入)。
// 不兜底的话 inputch 登记表里没有它,"把 inputch 划给驻留子"直接失败 // 不兜底的话 inputch 登记表里没有它,"把 inputch 划给驻留子"直接失败
// (实测报 `划入 inputch cli: inputch 未注册`)。兜底要**留痕** // (实测报 `划入 inputch cli: inputch 未注册`)。
// 否则插件作者永远不知道该补一行 RegisterInputChannel。 //
// ❗这里**不能**判"是否声明过入站通道"并告警:声明顺序是自由的,
// 先 RegisterOutputChannel 再 RegisterInputChannel 是常见写法qq 就是),
// 按此刻的状态判会对它误报(实测:把 qq 报成"只声明了输出通道")。
// 真正该问的问题是"插件 Start 结束后,这个出站通道有没有对应的入站声明" ——
// 那在 load 完成后统一判(见 warnOutputOnlyChannels
if _, ok := r.iom.LookupInputChannel(chName); !ok { if _, ok := r.iom.LookupInputChannel(chName); !ok {
_ = r.iom.RegisterInputChannelFrom(name, chName, agentIO.ChannelDef(def)) _ = r.iom.RegisterInputChannelFrom(name, chName, agentIO.ChannelDef(def))
log.Printf("[plugin] %s 只声明了输出通道 %q已按双向通道兜底登记 inputch"+
"若要明确意图请显式 RegisterInputChannel", name, chName)
} }
r.noteChannel(name, chName, true) r.noteChannel(name, chName, true)
return nil return nil
@ -452,6 +455,7 @@ func (r *Registry) Load(dir string) error {
r.pluginAutoRestart[name] = plgSDK.AutoRestart() r.pluginAutoRestart[name] = plgSDK.AutoRestart()
r.instances = append(r.instances, p) r.instances = append(r.instances, p)
r.mu.Unlock() r.mu.Unlock()
r.warnOutputOnlyChannels(name)
log.Printf("[plugin] loaded: %s", name) log.Printf("[plugin] loaded: %s", name)
} }
@ -545,6 +549,7 @@ func (r *Registry) loadOne(plgDir, name string) bool {
r.pluginAutoRestart[name] = plgSDK.AutoRestart() r.pluginAutoRestart[name] = plgSDK.AutoRestart()
r.sdkRefs[name] = plgSDK r.sdkRefs[name] = plgSDK
r.instances = append(r.instances, plg) r.instances = append(r.instances, plg)
r.warnOutputOnlyChannels(name)
if h := pluginEntryHash(plgDir); h != "" { if h := pluginEntryHash(plgDir); h != "" {
r.pluginHashes[name] = h r.pluginHashes[name] = h
} else { } else {
@ -579,6 +584,31 @@ func (r *Registry) stageRegistrarFor() (func(plugin string, stage sdk.Stage, han
return nil, false return nil, false
} }
// warnOutputOnlyChannels 在插件 Start 结束后,报告"只声明了出站、没有入站声明"的通道。
//
// 为什么放在 Start 之后:声明顺序自由(先出站后入站很常见),注册时刻的状态
// 判不出意图。这里看的是**插件最终声明了什么**,因此不会误报 qq 这种写法。
//
// 注:这类通道内核已兜底登记 inputch功能可用告警只是提醒插件作者把意图写明。
func (r *Registry) warnOutputOnlyChannels(plugin string) {
r.channelsMu.Lock()
set := r.pluginChannels[plugin]
var only []string
if set != nil {
for ch := range set.outputs {
if !set.inputs[ch] {
only = append(only, ch)
}
}
}
r.channelsMu.Unlock()
sort.Strings(only)
for _, ch := range only {
log.Printf("[plugin] %s 只声明了出站通道 %q未 RegisterInputChannel"+
"内核已兜底登记 inputch若这是有意为之可忽略", plugin, ch)
}
}
// noteChannel 记住插件注册了哪个通道,供卸载/崩溃时摘除。 // noteChannel 记住插件注册了哪个通道,供卸载/崩溃时摘除。
// forgetChannel 把某个通道从"本插件注册过哪些通道"的记账里摘掉(注销通道时用)。 // forgetChannel 把某个通道从"本插件注册过哪些通道"的记账里摘掉(注销通道时用)。
// //

View File

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

View File

@ -1,9 +1,9 @@
package remotedevice package remotedevice
// 设备输出通道:把"agent 主动发给设备"做成**每设备一个输出通道** `device/<id>`。 // 设备输出通道:把"agent 主动发给设备"做成**每设备一个输出通道** `device-<id>`。
// //
// 为什么是输出通道而不是再加一批工具: // 为什么是输出通道而不是再加一批工具:
// - **寻址**`output_send__device/<id>` 直接指名道姓;模型看 `output_list_channels` // - **寻址**`output_send__device-<id>` 直接指名道姓;模型看 `output_list_channels`
// 就知道当前有哪些设备在线,不必先 `devicedetect` 再往参数里塞 device_id。 // 就知道当前有哪些设备在线,不必先 `devicedetect` 再往参数里塞 device_id。
// - **能力**caps 由设备声明的 caps 映射,**内核**在发送前就按 caps 拦 // - **能力**caps 由设备声明的 caps 映射,**内核**在发送前就按 caps 拦
// (把图片发给只支持文本的音箱会被拒,而不是等设备侧报错)。 // (把图片发给只支持文本的音箱会被拒,而不是等设备侧报错)。
@ -15,7 +15,9 @@ package remotedevice
// 它们的返回值(图像/命令输出/状态)必须进模型上下文,做成通道会丢掉这个语义。 // 它们的返回值(图像/命令输出/状态)必须进模型上下文,做成通道会丢掉这个语义。
import ( import (
"crypto/sha1"
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
@ -76,14 +78,75 @@ func deviceOutputCaps(caps []string, kind string) agentIO.OutputCapability {
return out return out
} }
// deviceChannelName 是设备输出(也是输入)通道名:`device/<id>`。 // deviceChannelName 由**设备自报的 id** 派生一个合规且唯一的通道名:`device-<派生值>`。
// //
// 入站与出站**同名**:两者指的是同一台设备,分成两个名字只会让模型与授权表更难对。 // 入站与出站**同名**:两者指的是同一台设备,分成两个名字只会让模型与授权表更难对。
func deviceChannelName(id string) string { return "device/" + id } //
// 为什么不能直接用 id通道名会被内核拼进 LLM 的**函数名**`output_send__<通道名>`
// 上游规范是 `^[a-zA-Z0-9_-]{1,64}$`;而设备 id 是**外部输入**(设备自己声明),
// 可能含空格/非 ASCII/超长。违规的后果不是"这个工具不能用",而是**整条请求被 400 拒绝** ——
// 实测把生产打挂:`Invalid 'tools[299].function.name'`,网关 auto tier 全链条失败,
// 内核只能报"所有 provider 都失败",表现成"整个 agent 不说话了"。
//
// 派生规则(确定性,同一 id 永远同名):
// 1. 保留 [A-Za-z0-9_-],其它字符折成 '-';折叠后为空则用 "dev"
// 2. 截断到 maxDeviceChannelSuffix 字符(给 "device-" 与短哈希留余量)
// 3. 若发生截断,或该名字已被**另一个** id 占用,则追加 id 的 6 位短哈希
//
// 设备 id 本身仍用于路由与日志(真名不丢),通道名只是它派生的标识符。
func (p *Plugin) deviceChannelName(id string) string {
p.devChansMu.Lock()
defer p.devChansMu.Unlock()
if p.devChans == nil {
p.devChans = make(map[string]string)
}
if name, ok := p.devChans[id]; ok {
return name
}
var b strings.Builder
for _, r := range id {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
b.WriteRune(r)
default:
b.WriteByte('-')
}
}
base := b.String()
if base == "" {
base = "dev"
}
truncated := false
if len(base) > maxDeviceChannelSuffix {
base = base[:maxDeviceChannelSuffix]
truncated = true
}
name := "device-" + base
// 撞名检查:不同 id 折出同一个名字时必须可区分
for otherID, otherName := range p.devChans {
if otherName == name && otherID != id {
truncated = true
break
}
}
if truncated {
sum := sha1.Sum([]byte(id))
name += "-" + hex.EncodeToString(sum[:3])
}
p.devChans[id] = name
return name
}
const (
// maxDeviceChannelSuffix 是通道名主体的长度上限。
// 预算:上游函数名上限 64 = "output_send__"(13) + "device-"(7) + 主体 + "-"+短哈希(7)
// ⇒ 主体最多 37取 32 留余量(改名/前缀变动不会立刻越界)。
maxDeviceChannelSuffix = 32
)
// wireDeviceChannels 把"设备上下线"接到通道的登记/注销上。 // wireDeviceChannels 把"设备上下线"接到通道的登记/注销上。
// //
// 一台设备 = 一对**同名**通道 `device/<id>`:入站(设备上报 → agent与出站 // 一台设备 = 一对**同名**通道 `device-<id>`:入站(设备上报 → agent与出站
// agent → 设备)。用**同步回调**而不是 ChangeChan后者是 select+default // agent → 设备)。用**同步回调**而不是 ChangeChan后者是 select+default
// 缓冲满会丢事件;丢一次就留下死通道或漏注册)。 // 缓冲满会丢事件;丢一次就留下死通道或漏注册)。
// //
@ -92,14 +155,14 @@ func deviceChannelName(id string) string { return "device/" + id }
func (p *Plugin) wireDeviceChannels() { func (p *Plugin) wireDeviceChannels() {
p.registry.SetPresenceHandler( p.registry.SetPresenceHandler(
func(meta DeviceMeta) { func(meta DeviceMeta) {
_ = p.sdk.RegisterInputChannel(deviceChannelName(meta.DeviceID), sdk.ChannelDef{}) _ = p.sdk.RegisterInputChannel(p.deviceChannelName(meta.DeviceID), sdk.ChannelDef{})
p.ensureDeviceOutputChannel(meta.DeviceID) p.ensureDeviceOutputChannel(meta.DeviceID)
}, },
func(id string) { p.dropDeviceOutputChannel(id) }, func(id string) { p.dropDeviceOutputChannel(id) },
) )
} }
// ensureDeviceOutputChannel 给在线设备注册输出通道 device/<id>(幂等)。 // ensureDeviceOutputChannel 给在线设备注册输出通道 device-<id>(幂等)。
func (p *Plugin) ensureDeviceOutputChannel(id string) { func (p *Plugin) ensureDeviceOutputChannel(id string) {
if p.sdk == nil || id == "" { if p.sdk == nil || id == "" {
return return
@ -108,7 +171,7 @@ func (p *Plugin) ensureDeviceOutputChannel(id string) {
if !ok || !meta.Online { if !ok || !meta.Online {
return return
} }
ch := deviceChannelName(id) ch := p.deviceChannelName(id)
caps := deviceOutputCaps(meta.Caps, meta.Kind) caps := deviceOutputCaps(meta.Caps, meta.Kind)
desc := fmt.Sprintf("远程设备 %s%sagent 主动向该设备发送内容;能力位 %s", desc := fmt.Sprintf("远程设备 %s%sagent 主动向该设备发送内容;能力位 %s",
id, fallback(meta.Name, meta.Kind), agentIO.OutputCapability(caps).String()) id, fallback(meta.Name, meta.Kind), agentIO.OutputCapability(caps).String())
@ -130,7 +193,7 @@ func (p *Plugin) dropDeviceOutputChannel(id string) {
if p.sdk == nil || id == "" { if p.sdk == nil || id == "" {
return return
} }
ch := deviceChannelName(id) ch := p.deviceChannelName(id)
if err := p.sdk.UnregisterOutputChannel(ch); err != nil { if err := p.sdk.UnregisterOutputChannel(ch); err != nil {
p.logf("unregister output channel %s: %v", ch, err) p.logf("unregister output channel %s: %v", ch, err)
return return
@ -247,9 +310,9 @@ func (d *devicectlDevice) output(args map[string]interface{}) (interface{}, erro
ids = append(ids, m.DeviceID) ids = append(ids, m.DeviceID)
} }
if len(ids) == 0 { if len(ids) == 0 {
return nil, fmt.Errorf("devicectl 需要 meta.device_id 才能投递当前没有在线设备device_list_channels 可看每台设备的 device/<id> 通道)") return nil, fmt.Errorf("devicectl 需要 meta.device_id 才能投递当前没有在线设备device_list_channels 可看每台设备的 device-<id> 通道)")
} }
return nil, fmt.Errorf("devicectl 需要 meta.device_id或直接用通道 device/<id>);当前在线设备: %s", strings.Join(ids, ", ")) return nil, fmt.Errorf("devicectl 需要 meta.device_id或直接用通道 device-<id>);当前在线设备: %s", strings.Join(ids, ", "))
} }
return pushToDevice(d.reg, deviceID, args) return pushToDevice(d.reg, deviceID, args)
} }

View File

@ -9,6 +9,8 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"regexp"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
@ -130,7 +132,7 @@ func TestDeviceChannelLifecycleAndPush(t *testing.T) {
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"spk-1","name":"音箱","kind":"speaker","caps":["speaker"]}}`)) cli.sendText([]byte(`{"op":"hello","device":{"device_id":"spk-1","name":"音箱","kind":"speaker","caps":["speaker"]}}`))
cli.readHelloAckAndBind(t, token) cli.readHelloAckAndBind(t, token)
ch := deviceChannelName("spk-1") ch := p.deviceChannelName("spk-1")
deadline := time.Now().Add(3 * time.Second) deadline := time.Now().Add(3 * time.Second)
caps, ok := rec.caps(ch) caps, ok := rec.caps(ch)
for !ok && time.Now().Before(deadline) { for !ok && time.Now().Before(deadline) {
@ -255,3 +257,31 @@ func TestDevicectlAggregateOutputAddressing(t *testing.T) {
t.Fatal("不存在的设备应报错") t.Fatal("不存在的设备应报错")
} }
} }
// 通道名合规性:设备通道名会被内核拼进 LLM **函数名**output_send__<通道名>
// 而上游函数名规范是 ^[a-zA-Z0-9_-]{1,64}$ —— 违规会让**整条请求**被 400 拒绝
// 实测把生产打挂device/<id> 里的 `/` 触发 Invalid 'tools[299].function.name'
// 网关 auto tier 全链条失败,整个 agent 不说话了)。
//
// 通道名是**插件自己的声明**,所以这条判据钉在插件侧。
func TestDeviceChannelNameIsLLMFunctionNameSafe(t *testing.T) {
re := regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
// 含**恶意/异常** id空格、符号、非 ASCII、超长、以及会折成同一个名字的两个 id
ids := []string{"waiter-fnnas", "1", "a b!c", "中文设备", strings.Repeat("x", 120), "a b", "a-b"}
p := &Plugin{}
seen := map[string]string{}
for _, id := range ids {
ch := p.deviceChannelName(id)
if prev, dup := seen[ch]; dup {
t.Errorf("不同设备 id%q 与 %q派生出同一个通道名 %q", prev, id, ch)
}
seen[ch] = id
if !re.MatchString(ch) {
t.Errorf("设备通道名 %q 违反上游函数名规范 %s", ch, re)
}
toolName := "output_send__" + ch
if !re.MatchString(toolName) {
t.Errorf("派生出的工具名 %q 违反上游函数名规范 %s", toolName, re)
}
}
}

View File

@ -37,6 +37,11 @@ type Plugin struct {
token string token string
sdk *sdk.PluginSDK sdk *sdk.PluginSDK
dev *devicectlDevice dev *devicectlDevice
// devChansMu/devChans 维护"设备自报 id → 派生的通道名"。
// 设备 id 是外部输入,不能直接进通道名(见 outputch.go 的 deviceChannelName
devChansMu sync.Mutex
devChans map[string]string
} }
func New(name string) *Plugin { func New(name string) *Plugin {
@ -124,7 +129,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
// //
// **注意**agent 的输出**不会**被自动转回设备 —— 主动转发只有 webui 与 cli 两个 // **注意**agent 的输出**不会**被自动转回设备 —— 主动转发只有 webui 与 cli 两个
// 交互界面(它们把最终回复渲染成对话气泡是本职)。设备要走 // 交互界面(它们把最终回复渲染成对话气泡是本职)。设备要走
// `output_send__device/<id>`agent 主动调用),这才与"输出是 agent 的主动调用"一致。 // `output_send__device-<id>`agent 主动调用),这才与"输出是 agent 的主动调用"一致。
// 节流:同设备同类型事件 10s 内去重,防传感器风暴。 // 节流:同设备同类型事件 10s 内去重,防传感器风暴。
lastEventAt := map[string]time.Time{} lastEventAt := map[string]time.Time{}
var eventMu sync.Mutex var eventMu sync.Mutex
@ -159,9 +164,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
log.Printf("[remotedevice] event from %s: %s", deviceID, evtType) log.Printf("[remotedevice] event from %s: %s", deviceID, evtType)
if p.sdk != nil { if p.sdk != nil {
// 设备通道 device/<id> 是动态的:设备首次上报时**懒登记** inputch // 设备通道 device-<id> 是动态的(分隔符用 - 而非 /,见 deviceChannelName 的说明:
// Register 幂等),父 agent 才能把它划给驻留子 // 通道名会进 LLM 函数名,必须满足 ^[a-zA-Z0-9_-]{1,64}$
devCh := "device/" + deviceID // 首次上报时**懒登记** inputchRegister 幂等),父 agent 才能把它划给驻留子。
devCh := p.deviceChannelName(deviceID)
_ = p.sdk.RegisterInputChannel(devCh, sdk.ChannelDef{}) _ = p.sdk.RegisterInputChannel(devCh, sdk.ChannelDef{})
// 异步注入:不阻塞 WS 读循环;回复路由回 device/{id} 输出通道 // 异步注入:不阻塞 WS 读循环;回复路由回 device/{id} 输出通道
p.sdk.InjectInput(devCh, devCh, "text", map[string]interface{}{"content": text}) p.sdk.InjectInput(devCh, devCh, "text", map[string]interface{}{"content": text})

View File

@ -287,7 +287,7 @@ func (r *Registry) register(meta DeviceMeta) {
r.devices[meta.DeviceID] = &meta r.devices[meta.DeviceID] = &meta
onOnline := r.onOnline onOnline := r.onOnline
r.mu.Unlock() r.mu.Unlock()
// 先回调(可能注册 device/<id> 输出通道),再发变更通知。 // 先回调(可能注册 device-<id> 输出通道),再发变更通知。
if onOnline != nil { if onOnline != nil {
onOnline(meta) onOnline(meta)
} }

View File

@ -480,7 +480,15 @@ func (s *PluginSDK) RegisterPluginAPI(name string) error {
// 入站(谁会往 <name> 注入输入)是另一件事,用 RegisterInputChannel 声明。 // 入站(谁会往 <name> 注入输入)是另一件事,用 RegisterInputChannel 声明。
// 若该通道同时也是你的注入入口,两个都要登记。 // 若该通道同时也是你的注入入口,两个都要登记。
// //
// name: channel name (e.g. "qq", "webui") // name: channel name (e.g. "qq", "webui")
//
// ❗**命名约束**:内核会把通道名拼进 LLM 的函数名(`output_send__<name>`
// 而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。违反的后果不是"这个工具不可用"
// 而是**整条请求被上游 400 拒绝**`Invalid 'tools[N].function.name'`
// 网关的 auto tier 会全链条失败 —— 表现成"整个 agent 不说话了"。
// 所以通道名只能用 `[A-Za-z0-9_-]`,且总长要留出 `output_send__`13 字符)的余量。
// 若通道名来自外部输入(设备自报 id 之类),请**在插件侧派生一个合规且唯一的名字**
// 而不是把原始值直接当通道名。
// caps: bitmask of supported output capabilities (CapText, CapFile, etc.) // caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
// desc: description of the channel, expected meta format, and type enum // desc: description of the channel, expected meta format, and type enum
// def: 通道在记忆计算层的行为NoMemory/Cleaner // def: 通道在记忆计算层的行为NoMemory/Cleaner