feat(sdk): 通道方向契约落地 + 模板工程/示例插件显式登记 inputch + 生成器两处修正

## 背景:内核侧发现的真问题

在真实二进制压力测试里发现:插件只调 `RegisterOutputChannel("cli", ...)`,
却用同一个通道名 `InjectTextSync("cli", ...)` 注入输入 ⇒ 内核 inputch 登记表里
**没有**这个通道,"把 inputch 划给驻留子"直接失败(`划入 inputch cli: inputch 未注册`)。

根因是**契约没有落到插件与 SDK 面上**:inputch 是内核最基本的**输入路由单位**,
"谁会往这个通道注入输入"必须显式声明,而 SDK 文档没说清它与 RegisterOutputChannel
的分工,示例与模板工程也没有示范。

## SDK 面

- `RegisterInputChannel` / `RegisterOutputChannel` 的文档补齐**方向契约**:
  入站(谁会注入)与出站(output_send__<name> 的回复发给谁)是分开登记的两件事;
  凡是用 `InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...)` 注入的
  通道名都要 RegisterInputChannel。README 同步补了一段契约说明。

## 示例插件(全部补齐,之前只有 qq/weather 是对的)

`a2a`、`acp`、`browser`、`memo`:注入用 `p.name` ⇒ 登记 `p.name`;
`calendar`、`rss`:注入用字面量通道名 ⇒ 登记同名通道。
(这些插件此前是"能注入、但通道不在登记表里",与 cli 同类问题。)

## 模板工程(生成器 templates.go)

- `tmplPluginGo`:示范入站+出站两个方向(含 ChannelDef/NoMemory 说明与 `inputch 未注册` 的成因)。
- `tmplMainLua`:同样两个方向(`register_input_channel` / `register_output_channel`)。
- `tmplReadme`:新增 "Channels" 一节(方向对照表 + 兜底告警说明)。
- 实测:`hmapdev init` 生成的 Go/Lua 工程都含通道代码,Go 工程可构建打包出 `.hmap`;
  `--lua` 工程同样生成通道代码。

## 生成器两处修正(都是实测踩出来的)

1. `sdk install --from <dir>`:install 原本只能从 Release 归档下载,而 SDK 开发期的新能力
   (如 proc 桥要透传的 `InjectOptions.Priority`)还没发版 ⇒ 生成的工程必然编译失败
   (`z_proc_gen.go: opts.Priority undefined`)。现在可用本地源码装一个版本并激活。
   实测:`hmapdev sdk install --from <local sdk>` → 装成 v1.3.0 并激活 → 工程构建通过。
2. 构建前置校验 `sdkHasInjectPriority`:proc 桥模板需要 `InjectOptions.Priority`,
   旧 SDK 没有时应给出**可执行**的报错(升级 SDK 或用 `--from`),
   而不是把两条 `opts.Priority undefined` 编译错误甩给用户(那些错误指向生成物,
   完全看不出是 SDK 版本问题)。实测:声明 sdk=1.2.0 的工程构建时正确命中该提示。

## 未决(发布期事项)

`InjectOptions.Priority` 属本特性线新增能力,**已发布的 SDK v1.2.0 不含它**;
发版时 SDK 版本需随之内含该能力(当前源码 meta 已是 1.3.0),否则外部开发者
按文档生成的工程会撞上上面那条守卫。
This commit is contained in:
JianFeeeee
2026-09-13 11:37:36 +08:00
parent e50bffa34f
commit 4cb3a0bda4
11 changed files with 219 additions and 49 deletions

View File

@ -84,11 +84,15 @@ type Plugin interface {
通过 `Start(sdk *PluginSDK)` 注入的 SDK 实例提供以下方法:
> **通道的方向契约**:入站与出站是分开登记的两件事。凡是用 `InjectText*/InjectInput*/InjectInterrupt*`
> 注入的通道名都要 `RegisterInputChannel` —— inputch 是内核最基本的**输入路由单位**
> 只有登记过的通道才能被"划给驻留子";只登记出站通道时内核会兜底登记同名 inputch 并告警(兼容老插件)。
| 分类 | 方法 | 说明 |
|------|------|------|
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道def 为 `ChannelDef`NoMemory/Cleaner |
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道def 为 `ChannelDef`caps 为能力位掩码 |
| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道**入站**:谁会往这个通道注入输入)def 为 `ChannelDef`NoMemory/Cleaner |
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道**出站**`output_send__<name>` 的回复发给谁)def 为 `ChannelDef`caps 为能力位掩码 |
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
| 图记忆 | `Memory()` | 访问图记忆 API实体-关系存储) |

View File

@ -47,6 +47,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.sessions = make(map[string]*a2aSession)
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
tp := p.name + "_"
// 注册自身为输出通道agent 回复 emit 到本通道时有落点,

View File

@ -47,6 +47,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.sessions = make(map[string]*sessionState)
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
tp := p.name + "_"
// 注册自身为输出通道agent 回复 emit 到本通道时有落点。

View File

@ -204,6 +204,9 @@ func newHTTPClient(timeout int, proxyURL string) *http.Client {
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
s.SetAutoRestart(true)
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "timeout", Default: "30", Type: "int",

View File

@ -276,6 +276,9 @@ func nextLunarYearly(targetMonth, targetDay int, after time.Time) (time.Time, bo
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
// 入站通道:本插件用 "calendar" 通道注入输入(见 Inject* 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel("calendar", sdk.ChannelDef{NoMemory: true})
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
if err != nil || dataDirVal == "" {
dataDirVal = "."

View File

@ -48,6 +48,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.tp = p.name + "_"
// 入站通道:本插件用 p.name 通道注入输入(见 Inject* 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{NoMemory: true})
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
if err != nil || dataDirVal == "" {

View File

@ -104,6 +104,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.client = &http.Client{Timeout: 30 * time.Second}
// 入站通道:本插件用 "rss" 通道注入输入(见 Inject* 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel("rss", sdk.ChannelDef{NoMemory: true})
p.fp = gofeed.NewParser()
p.stopCh = make(chan struct{})
p.seenGUIDs = make(map[string]bool)
@ -488,8 +491,6 @@ func (p *Plugin) cleanupData() {
}
}
// atomicWriteJSON 原子写 JSON先写临时文件再 rename避免进程崩溃截断数据文件。
func atomicWriteJSON(path string, data []byte) error {
tmp := path + ".tmp"

View File

@ -467,6 +467,11 @@ func (s *PluginSDK) RegisterPluginAPI(name string) error {
}
// RegisterOutputChannel registers an output channel that the output_send tool can route to.
//
// 与 RegisterInputChannel 的分工:本函数声明**出站**output_send__<name> 的回复发给谁);
// 入站(谁会往 <name> 注入输入)是另一件事,用 RegisterInputChannel 声明。
// 若该通道同时也是你的注入入口,两个都要登记。
//
// name: channel name (e.g. "qq", "webui")
// caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
// desc: description of the channel, expected meta format, and type enum
@ -483,6 +488,15 @@ func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, de
}
// RegisterInputChannel registers an input channel with its memory behavior.
//
// 契约:**凡是用 InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...)
// 注入的通道名,都应当在这里登记**。inputch 是内核里最基本的**输入路由单位**
// 只有登记过的通道才能在 inputch 登记表里出现,父 agent 才能"把某个 inputch 划给驻留子"
// 没登记就划分会直接失败(`inputch 未注册`)。
//
// 只登记输出通道RegisterOutputChannel而没登记输入通道时内核会兜底登记同名
// inputch 并打告警日志 —— 兜底只为兼容老插件,新插件请显式登记。
//
// def.NoMemory: 此通道输入不参与记忆计算
// def.Cleaner: 计算层对输入文本清洗后(不改原文)再向量化/提关键词
func (s *PluginSDK) RegisterInputChannel(name string, def ChannelDef) error {

View File

@ -95,6 +95,18 @@ func cmdBuild(args []string) {
plg.ResolvedSDK = normalizeSDKVersion(readMetaVersion(sdkPath))
}
// SDK 能力前置校验proc 桥的模板z_proc_gen.go会透传 InjectOptions.Priority
// 而旧版 SDK 没有这个字段。不校验的话,用户看到的是 z_proc_gen.go 里两条
// "opts.Priority undefined" 编译错误——错误信息指向生成物,完全看不出是 SDK 版本问题。
if sdkPath != "" && !sdkHasInjectPriority(sdkPath) {
fmt.Printf("error: 当前 SDK%s缺少 sdk.InjectOptions.Priority\n", plg.ResolvedSDK)
fmt.Printf(" 子进程模式proc 桥)的模板需要它来透传注入优先级 L1-L4。\n")
fmt.Printf(" 解决办法(二选一):\n")
fmt.Printf(" 1) 升级 SDKhmapdev sdk install <含该能力的版本> && hmapdev sdk use <版本>\n")
fmt.Printf(" 2) 用本地 SDK 源码hmapdev sdk install --from /path/to/homeagent-sdk\n")
os.Exit(1)
}
// Ensure go.mod exists with correct SDK path
sdkModule := ensureGoMod(plg, sdkPath)
@ -869,3 +881,22 @@ func linkThirdpart(plg *PlgConfig, target string) func() {
os.Remove(importFile)
}
}
// sdkHasInjectPriority 报告该 SDK 源码是否已具备 InjectOptions.Priority
// proc 桥透传注入优先级所必需的能力SDK 开发期与已发布版本可能不一致)。
func sdkHasInjectPriority(sdkPath string) bool {
data, err := os.ReadFile(filepath.Join(sdkPath, "sdk", "plugin.go"))
if err != nil {
return true // 读不到就不拦(不在校验范围内)
}
src := string(data)
i := strings.Index(src, "type InjectOptions struct")
if i < 0 {
return true
}
seg := src[i:]
if j := strings.Index(seg, "\n}"); j > 0 {
seg = seg[:j]
}
return strings.Contains(seg, "Priority")
}

View File

@ -58,6 +58,27 @@ func cmdSDK(args []string) {
sdkHelp()
return
}
// install --from <本地目录> [version]:用本地 SDK 源码装一个版本并激活。
if args[0] == "install" {
from := ""
rest := []string{}
for i := 1; i < len(args); i++ {
if args[i] == "--from" && i+1 < len(args) {
from = args[i+1]
i++
continue
}
rest = append(rest, args[i])
}
if from != "" {
version := ""
if len(rest) > 0 && rest[0] != "latest" {
version = rest[0]
}
cmdSDKInstallFromDir(from, version)
return
}
}
switch args[0] {
case "list":
cmdSDKList()
@ -100,7 +121,8 @@ Commands:
Examples:
hmapdev sdk install v0.7.1
hmapdev sdk install latest
hmapdev sdk use v0.7.1
hmapdev sdk install v0.7.1
hmapdev sdk install --from /path/to/homeagent-sdk # 用本地源码SDK 开发时用sdk use v0.7.1
`)
}
@ -144,6 +166,51 @@ func cmdSDKList() {
}
}
// cmdSDKInstallFromDir 从**本地 SDK 源码目录**安装一个版本。
//
// 为什么需要它:`install` 只能从 Release 归档下载,而 SDK 开发时的新能力
// (例如 `InjectOptions.Priority` 这类 proc 桥要透传的字段)往往还没发版 ——
// 此时生成出来的插件工程会因为"引用的 SDK 还没有该字段"直接编译失败。
// 有 --from 才能"用本地源码当这个版本的 SDK",边改 SDK 边验证模板工程。
func cmdSDKInstallFromDir(src, version string) {
store := sdkStore()
if err := os.MkdirAll(store, 0755); err != nil {
fmt.Printf("error: create SDK store %s: %v\n", store, err)
os.Exit(1)
}
if version == "" {
version = readMetaVersion(src)
}
if version == "" {
fmt.Printf("error: cannot determine version from %s/meta/meta.go\n", src)
os.Exit(1)
}
if !strings.HasPrefix(version, "v") {
version = "v" + version
}
if _, err := os.Stat(filepath.Join(src, "go.mod")); err != nil {
fmt.Printf("error: %s 看起来不是 SDK 源码目录(缺 go.mod\n", src)
os.Exit(1)
}
dest := sdkVersionDir(version)
_ = os.RemoveAll(dest)
if err := copyDir(src, dest); err != nil {
fmt.Printf("error: copy %s -> %s: %v\n", src, dest, err)
os.Exit(1)
}
// 源码目录里的开发产物不该带进 store。
for _, junk := range []string{".git", "dist", "build"} {
_ = os.RemoveAll(filepath.Join(dest, junk))
}
fmt.Printf("Installed SDK %s from %s\n", version, src)
fmt.Printf(" %s\n", dest)
if err := os.WriteFile(filepath.Join(store, "current"), []byte(version), 0644); err != nil {
fmt.Printf("error: activate %s: %v\n", version, err)
os.Exit(1)
}
fmt.Printf("Activated SDK %s\n", version)
}
// cmdSDKInstall downloads and installs an SDK version from Release archive.
func cmdSDKInstall(version string) {
store := sdkStore()
@ -520,5 +587,3 @@ func readMetaVersion(sdkRoot string) string {
}
return "0.0.0"
}

View File

@ -49,6 +49,22 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
DisplayName: "示例配置", Description: "An example configuration key",
Category: "{{.Plg.Name}}",
})
// ---- 通道channel两个方向是分开的两件事 ----
//
// 入站 inputch ——「谁会往这个通道注入输入」。
// 凡是用 s.InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...) 注入的通道名,
// 都要在这里登记inputch 是内核最基本的**输入路由单位**,只有登记过的通道
// 才能被「划给驻留子resident sub-agent没登记就划分会失败inputch 未注册)。
// 只登记出站通道时内核会兜底登记同名 inputch **并打告警**(兼容老插件)。
chName := p.name
_ = s.RegisterInputChannel(chName, sdk.ChannelDef{NoMemory: true})
// 出站 output ——「output_send__<name> 的回复发给谁」。
// handler 收到 mappayload(string) / type(string) / meta(string|optional)。
_ = s.RegisterOutputChannel(chName, sdk.CapText, "示例通道(回复由此返回)",
sdk.ChannelDef{NoMemory: true}, func(args map[string]interface{}) (interface{}, error) {
return map[string]interface{}{"status": "ok"}, nil
})
tp := p.name + "_"
s.RegisterTool(tp+"hello", sdk.ToolDef{
Name: tp + "hello",
@ -148,6 +164,15 @@ const tmplMainLua = `-- {{.Plg.Name}} plugin
local plugin = { name = "{{.Plg.Name}}" }
function plugin.start(sdk)
sdk.log("info", "{{.Plg.Name}} starting...")
-- 通道:入站与出站分开登记。
-- 入站 inputch凡是用 sdk.inject_text/sdk.inject_interrupt(source, "<name>", ...) 注入的通道名
-- 都要登记;只有登记过的通道才能被「划给驻留子」(没登记会报 inputch 未注册)。
sdk.register_input_channel("{{.Plg.Name}}", { no_memory = true })
-- 出站 outputoutput_send__<name> 的回复由 handler 处理
sdk.register_output_channel("{{.Plg.Name}}", 1, "示例通道(回复由此返回)", { no_memory = true },
function(args) return { status = "ok" } end)
sdk.register_tool("{{.Plg.Name}}_hello", {
description = "A hello world tool",
parameters = { type = "object", properties = {} }
@ -515,6 +540,21 @@ const tmplReadme = `# {{.Plg.Name}}
hmapdev build
` + "```" + `
## Channels
入站与出站是分开登记的两件事:
| 方向 | API | 用途 |
|---|---|---|
| 入站 inputch | RegisterInputChannel(name, def) | 声明「谁会往这个通道注入输入」。**凡是用 InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...) 注入的通道名都要登记** |
| 出站 output | RegisterOutputChannel(name, caps, desc, def, handler) | 声明 output_send__<name> 的回复发给谁handler 收到 {payload,type,meta} |
defChannelDef描述该通道在记忆计算层的行为NoMemory: true = 该通道输入不进记忆;
Cleaner = 计算层清洗后再向量化/提关键词(原文不改)。
> 只登记出站通道、却用同名通道注入输入时,内核会兜底登记同名 inputch 并在日志里告警。
> 兜底只为兼容老插件 —— 请显式登记,让「这是入站通道」成为插件的明确意图。
## Install
Upload the .hmap file through the Plugin Manager API.