# HomeAgent 插件 SDK — 完整文档 (本文件由 tools/apidoc/gensite 从 docs/ 汇总生成,供 agent 一次读取。) --- ## # HomeAgent 插件 SDK 用 **Go** 或 **Lua** 为 HomeAgent 编写插件。插件跑在独立进程里,通过公开 SDK 与内核通信: 注册工具供模型调用、挂阶段钩子干预流程、读写三层记忆、注册输入输出通道、订阅事件。 ## 从这里开始
- :material-rocket-launch: **第一次写插件** 装工具链、生成工程、写一个工具、打包成 `.hmap` 装进内核跑起来。 [:octicons-arrow-right-24: 快速开始](guide/getting-started.md) - :material-book-open-variant: **API 参考** 逐个符号的签名与说明,直接取自源码注释。附示例插件里的真实调用点。 [:octicons-arrow-right-24: 工具(Tools)](api/tools.md) - :material-shield-lock: **能力边界** 哪些 API 外部插件能用、哪些仅内置插件可用,以及为什么。**先看这个能省很多时间。** [:octicons-arrow-right-24: 能力边界](guide/capability-boundary.md) - :material-code-braces: **示例插件** `example/` 下有多个真实可编译的插件,覆盖常见形态。 [:octicons-arrow-right-24: 示例总览](examples/index.md)
## 许可 SDK 以 **MIT** 发布 —— 插件作者可**自由选择自己的许可**(闭源、商业、私有均可), 不必同许可、也不必回馈。原因:SDK 会随插件一起静态链接(源码进入插件二进制), 若用传染性许可,插件作者就被强制开源;MIT 让第三方插件生态不必承担这个代价。 内核本身是 **AGPL-3.0-only**,但那是内核的许可,与外部插件无关 —— SDK 完全自包含(`go.mod` 零外部依赖,只依赖 Go 标准库),不引用内核任何代码。 ## 版本 本文档站的 API 参考从源码生成,对应 SDK 版本见 [版本与兼容](versions.md)。 ## # 桥接装配点(Bridge) 以下方法**不是给插件业务代码调的**——它们由 `hmapdev` 生成的运行时在启动时调用,用来把内核能力注入到 SDK 实例。列在这里是为了让「公开 API 面」完整,并说明每个注入点对应什么能力。 ### `APIRegistrar` ```go type APIRegistrar func(name string) error ``` APIRegistrar registers a plugin API for external access. `plugin.go:410` ### `InputChannelRegistrar` ```go type InputChannelRegistrar func(name string, def ChannelDef) error ``` InputChannelRegistrar registers an input channel with its memory behavior. `plugin.go:413` ### `OutputChannelRegistrar` ```go type OutputChannelRegistrar func(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error ``` OutputChannelRegistrar registers an output channel that the output_send tool can use. `plugin.go:416` ### `OutputChannelUnregistrar` ```go type OutputChannelUnregistrar func(name string) error ``` OutputChannelUnregistrar 注销一个输出通道。 为什么需要它:输出通道不止有"启动时注册一次"的静态通道,还有**随外部资源生灭**的 动态通道 —— 典型是远程设备:`device/` 只在设备在线期间存在,设备掉线后 必须注销,否则 output_list_channels 会一直列着它、模型会往一个死通道发消息。 `plugin.go:423` ### `PluginSDK.SetDocMemoryAPI` !!! info "桥接装配点" 桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用) ```go func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI) ``` `plugin.go:718` ### `PluginSDK.SetEventSubscriber` !!! warning "仅内核内置插件可用" 同上:无调用点。标 builtin 而非 bridge,因为连桥接运行时都不注入它——外部插件无法用它获得事件订阅能力。 ```go func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) ``` `plugin.go:742` ### `PluginSDK.SetIOInjector` !!! info "桥接装配点" 桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用) ```go func (s *PluginSDK) SetIOInjector(io IOInjector) ``` SetIOInjector sets the IO injector (called by the core at startup). `plugin.go:699` ### `PluginSDK.SetInputChannelRegistrar` !!! info "桥接装配点" 桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用) ```go func (s *PluginSDK) SetInputChannelRegistrar(r InputChannelRegistrar) ``` SetInputChannelRegistrar sets the input channel registrar (called by the core at startup). `plugin.go:692` ### `PluginSDK.SetKnowledgeAPI` !!! info "桥接装配点" 桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用) ```go func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI) ``` `plugin.go:724` ### `PluginSDK.SetLLMAPI` !!! info "桥接装配点" 桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用) ```go func (s *PluginSDK) SetLLMAPI(llm LLMAPI) ``` `plugin.go:730` ### `PluginSDK.SetMemoryAPI` !!! info "桥接装配点" 桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用) ```go func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI) ``` SetMemoryAPI sets the memory API (called by the core at startup). `plugin.go:706` ### `PluginSDK.SetOutputChannelRegistrar` !!! info "桥接装配点" 桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用) ```go func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar) ``` SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup). `plugin.go:678` ### `PluginSDK.SetOutputChannelUnregistrar` !!! warning "仅内核内置插件可用" 同上,桥接模板不注入。 ```go func (s *PluginSDK) SetOutputChannelUnregistrar(r OutputChannelUnregistrar) ``` SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup). `plugin.go:685` ### `PluginSDK.SetPluginMgrAPI` !!! info "桥接装配点" 桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用) ```go func (s *PluginSDK) SetPluginMgrAPI(pm PluginMgrAPI) ``` SetPluginMgrAPI sets the plugin manager API (called by the bridge at startup). `plugin.go:749` ### `PluginSDK.SetSocialAPI` !!! info "桥接装配点" 桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用) ```go func (s *PluginSDK) SetSocialAPI(social SocialAPI) ``` `plugin.go:736` ### `PluginSDK.SetTextMemoryAPI` !!! info "桥接装配点" 桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用) ```go func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI) ``` `plugin.go:712` ### `ToolRegistrar` ```go type ToolRegistrar func(name string, def ToolDef, handler ToolHandler) error ``` ToolRegistrar registers a tool dynamically. `plugin.go:404` ## # 仅内置插件可用的 API 这些 API **存在于公开 SDK 包里**,但在外部(第三方)插件的运行路径上不可用:要么桥接运行时根本不注入它(拿到 nil),要么内核会拒绝/降级。列在这里是为了让边界显式——而不是让你在运行时才发现拿不到。 判断依据全部来自源码与 `hmapdev` 桥接模板,逐条记在每条说明里。 ### `PriorityL4` !!! warning "仅内核内置插件可用" sdk/plugin.go:119-126 明确:L1..L3 任何插件可声明,L4 只有内核级插件可用,外部插件声明会被内核夹到 L3(内核侧有 priority_clamp_test.go 守着)。外部插件文档应说明「声明 L4 无效」。 ```go const PriorityL4 ``` PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。 `plugin.go:165` ### `PluginSDK.Events` !!! warning "仅内核内置插件可用" 实测全仓 SetEventSubscriber 只有定义、无任何调用点(grep -rn SetEventSubscriber --include=*.go 仅命中 sdk/plugin.go 的定义与 lua_plugin.go 的一句注释)。外部插件拿到的 Events() 恒为 nil。外部插件订阅事件走的是桥接运行时的 events.subscribe RPC(proc_main.go.tmpl:1421 在插件侧分发、内核 protocol.go MethodEventsSubscribe),Lua 插件则走内部 SDK 的 Subscribe。这正是 PLUGIN_DEV.md 里没有说清的一处。 ```go func (s *PluginSDK) Events() EventSubscriber ``` Events returns the event subscriber for listening to kernel events (may be nil if not available). `plugin.go:550` ### `PluginSDK.SetEventSubscriber` !!! warning "仅内核内置插件可用" 同上:无调用点。标 builtin 而非 bridge,因为连桥接运行时都不注入它——外部插件无法用它获得事件订阅能力。 ```go func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) ``` `plugin.go:742` ### `PluginSDK.SetOutputChannelUnregistrar` !!! warning "仅内核内置插件可用" 同上,桥接模板不注入。 ```go func (s *PluginSDK) SetOutputChannelUnregistrar(r OutputChannelUnregistrar) ``` SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup). `plugin.go:685` ### `PluginSDK.UnregisterOutputChannel` !!! warning "仅内核内置插件可用" 外部插件的桥接模板只注入 SetOutputChannelRegistrar(proc_main.go.tmpl:693-705),**未**注入 SetOutputChannelUnregistrar(grep Unregistrar 在 templates/ 下无命中)。因此外部插件的 regOutputUnreg 为 nil,UnregisterOutputChannel 会命中 `if reg != nil` 的 else 分支**直接返回 nil**(sdk/plugin.go:539-549)——即**静默无效**:不报错、通道也没注销。内置插件由 internal/sdk/plugin.go:330 注入 cfg.RegOutputUnreg,真正生效。外部插件要让通道下线,只能重载插件。 ```go func (s *PluginSDK) UnregisterOutputChannel(name string) error ``` UnregisterOutputChannel 注销一个输出通道(动态通道随资源生灭时必须调用)。 `plugin.go:643` ### `EventSubscriber.Subscribe` !!! warning "仅内核内置插件可用" ```go Subscribe(eventType EventType, handler EventHandler) func() ``` ## # 输入 / 输出通道 通道是插件与外界(设备、其他 Agent、外部系统)交换消息的入口。**输入通道**接收外部消息,**输出通道**把消息投递出去。 ## `IOInjector` IOInjector provides methods for injecting input and interrupts into the agent pipeline. All methods accept (source, channel) where channel is the target output channel for routing the agent's response. | 方法 | 说明 | |---|---| | [`InjectInputMedia`](#ioinjectorinjectinputmedia) | | | [`InjectInputMediaOpts`](#ioinjectorinjectinputmediaopts) | | | [`InjectInputMediaSync`](#ioinjectorinjectinputmediasync) | | | [`InjectInputMediaSyncOpts`](#ioinjectorinjectinputmediasyncopts) | | | [`InjectInputSync`](#ioinjectorinjectinputsync) | InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。 | | [`InjectInputSyncOpts`](#ioinjectorinjectinputsyncopts) | | | [`InjectInterruptMedia`](#ioinjectorinjectinterruptmedia) | | | [`InjectInterruptMediaOpts`](#ioinjectorinjectinterruptmediaopts) | | | [`InjectInterruptText`](#ioinjectorinjectinterrupttext) | | | [`InjectInterruptTextOpts`](#ioinjectorinjectinterrupttextopts) | | | [`InjectText`](#ioinjectorinjecttext) | | | [`InjectTextNoMemory`](#ioinjectorinjecttextnomemory) | | | [`InjectTextOpts`](#ioinjectorinjecttextopts) | 以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。 | | [`SetToolBlocks`](#ioinjectorsettoolblocks) | SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条 | ### `IOInjector.InjectInputMedia` ```go InjectInputMedia(source, channel, text string, blocks []ContentBlock) ``` `plugin.go:329` ### `IOInjector.InjectInputMediaOpts` ```go InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) ``` `plugin.go:340` ### `IOInjector.InjectInputMediaSync` ```go InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string ``` `plugin.go:330` ### `IOInjector.InjectInputMediaSyncOpts` ```go InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string ``` `plugin.go:341` ### `IOInjector.InjectInputSync` ```go InjectInputSync(source, channel, text string) string ``` InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。 用于通道消息的完整闭环:收到入站 → agent 处理 → 回复取回 → 送回通道。 **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` | | [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` | `plugin.go:325` ### `IOInjector.InjectInputSyncOpts` ```go InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string ``` `plugin.go:339` ### `IOInjector.InjectInterruptMedia` ```go InjectInterruptMedia(source, channel, text string, blocks []ContentBlock) ``` `plugin.go:331` ### `IOInjector.InjectInterruptMediaOpts` ```go InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) ``` `plugin.go:342` ### `IOInjector.InjectInterruptText` ```go InjectInterruptText(source, channel, text string) ``` `plugin.go:320` ### `IOInjector.InjectInterruptTextOpts` ```go InjectInterruptTextOpts(source, channel, text string, opts InjectOptions) ``` **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1319` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` | | [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:527` | `p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})` | | [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` | | [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` | `plugin.go:338` ### `IOInjector.InjectText` ```go InjectText(source, channel, text string) ``` `plugin.go:321` ### `IOInjector.InjectTextNoMemory` ```go InjectTextNoMemory(source, channel, text string) ``` **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` | `plugin.go:322` ### `IOInjector.InjectTextOpts` ```go InjectTextOpts(source, channel, text string, opts InjectOptions) ``` 以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。 上面那些不带 opts 的方法等价于传零值 InjectOptions(记入记忆 + 不裁剪), 保留它们是为了不破坏已有插件;新代码应当用 Opts 变体把意图写清楚。 `plugin.go:337` ### `IOInjector.SetToolBlocks` ```go SetToolBlocks(blocks []ContentBlock) ``` SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条 tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。 `plugin.go:328` ### `CapAudio` ```go const CapAudio ``` Output capability flags `plugin.go:430` ### `CapFile` ```go const CapFile ``` Output capability flags `plugin.go:428` ### `CapImage` ```go const CapImage ``` Output capability flags `plugin.go:429` ### `CapStructured` ```go const CapStructured ``` Output capability flags `plugin.go:431` ### `CapText` ```go const CapText ``` Output capability flags `plugin.go:427` ### `ChannelDef` ```go type ChannelDef struct { NoMemory bool `json:"no_memory,omitempty"` Cleaner func(string) string `json:"-"` ContextPolicy string `json: … ``` ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。 NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中 Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用 ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪) RecallPolicy: 此通道的输入到达后是否据此召回相关记忆,默认 auto(召回) ScenePolicy: 此通道的输入到达后是否参与场面识别(场景式记忆),默认 auto(参与) JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。 没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单—— 那样新增字段会被静默丢掉。 `plugin.go:178` ### `ContextPolicyNone` ```go const ContextPolicyNone ``` 上下文策略:决定一次工具调用/输入/注入是否依据其内容裁剪上下文。 默认(空串或 ContextPolicyNone)**不裁剪**:裁剪会归档丢弃低相关事件, 必须由工具/通道/注入点显式声明才发生——否则一个只想往上下文里塞内容的 插件会在背后把别人的内容挤掉,且看不出是谁干的。 `plugin.go:44` ### `ContextPolicyPrune` ```go const ContextPolicyPrune ``` 上下文策略:决定一次工具调用/输入/注入是否依据其内容裁剪上下文。 默认(空串或 ContextPolicyNone)**不裁剪**:裁剪会归档丢弃低相关事件, 必须由工具/通道/注入点显式声明才发生——否则一个只想往上下文里塞内容的 插件会在背后把别人的内容挤掉,且看不出是谁干的。 `plugin.go:45` ### `PluginSDK.InjectInputMedia` ```go func (s *PluginSDK) InjectInputMedia(source, channel, text string, blocks []ContentBlock) ``` InjectInputMedia 注入带媒体内容块(image_url/audio_url)的输入。 blocks 会落进媒体存储被记忆引用捕获,同时作为当前轮 content 数组 发给 LLM,让模型在「本轮」就看到图/听到音频——区别于 SetToolBlocks 的「下一轮 tool message」语义。 等价于 InjectInputMediaOpts(..., InjectOptions{})。 `plugin.go:805` ### `PluginSDK.InjectInputMediaOpts` ```go func (s *PluginSDK) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) ``` InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。 `plugin.go:844` ### `PluginSDK.InjectInputMediaSync` ```go func (s *PluginSDK) InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string ``` InjectInputMediaSync 注入带媒体内容块的输入并同步等待 agent 回复。 等价于 InjectInputMediaSyncOpts(..., InjectOptions{})。 `plugin.go:811` ### `PluginSDK.InjectInputMediaSyncOpts` ```go func (s *PluginSDK) InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string ``` InjectInputMediaSyncOpts 注入带媒体块的输入并同步等待回复,同时声明记忆/裁剪行为。 `plugin.go:851` ### `PluginSDK.InjectInputSync` ```go func (s *PluginSDK) InjectInputSync(source, channel, text string) string ``` InjectInputSync injects a text message and synchronously waits for the agent reply, returning the reply text (empty string if none). Replies must be dispatched back to the source channel by the caller. **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` | | [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` | `plugin.go:796` ### `PluginSDK.InjectInputSyncOpts` ```go func (s *PluginSDK) InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string ``` InjectInputSyncOpts 注入输入并同步等待回复,同时在这次注入上声明记忆/裁剪行为。 `plugin.go:835` ### `PluginSDK.InjectInterruptMedia` ```go func (s *PluginSDK) InjectInterruptMedia(source, channel, text string, blocks []ContentBlock) ``` InjectInterruptMedia 注入带媒体内容块的中断,可抢占当前 LLM 处理。 blocks 随中断消息一起发给模型。 `plugin.go:868` ### `PluginSDK.InjectInterruptMediaOpts` ```go func (s *PluginSDK) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) ``` InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。 `plugin.go:860` ### `PluginSDK.InjectInterruptText` ```go func (s *PluginSDK) InjectInterruptText(source, channel, text string) ``` InjectInterruptText injects a text interrupt that can preempt current LLM processing. 等价于 InjectInterruptTextOpts(..., InjectOptions{}):记入记忆、不裁剪。 `plugin.go:777` ### `PluginSDK.InjectInterruptTextOpts` ```go func (s *PluginSDK) InjectInterruptTextOpts(source, channel, text string, opts InjectOptions) ``` InjectInterruptTextOpts 注入可抢占当前处理的中断文本。 中断也允许声明 ContextPolicyPrune:中断同样携带内容进入上下文, 是否需要据此裁剪由调用方决定(默认不裁剪)。 **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1319` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` | | [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:527` | `p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})` | | [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` | | [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` | `plugin.go:828` ### `InjectOptions` ```go type InjectOptions struct { NoMemory bool ContextPolicy string // RecallPolicy 声明此次注入是否据其内容召回相关记忆。 // 空串 = 默认(输入/注�� … ``` InjectOptions 声明一次注入行为在记忆层与上下文层的表现。 零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致, 因此调用方只有在确实需要改变行为时才需要填它。 为什么注入也要这两个标志:注入的内容来源千差万别——轮询到的频道消息 属于真实对话(该记),而“任务还在跑”“连接已重连”这类提醒不该污染记忆, 也不该把上下文按它的内容裁一遍。按调用点声明比按通道一刀切准确。 NoMemory: 此次注入不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文 ContextPolicy: 此次注入后是否依据(清洗后的)内容裁剪上下文;默认不裁剪。 RecallPolicy: 此次注入是否依据(清洗后的)内容召回相关记忆;默认 auto(召回)。 中断注入也允许声明 prune——它同样会携带内容进入上下文。 CleanerName: 此次注入的内容用哪个**已注册的通道 cleaner** 清洗。 空串 = 按注入的 source 查通道定义(既有行为)。 为什么要能显式指定:注入的 source 未必是注册过的输入通道名, 而注入内容往往带 ANSI/JSON 包装,需要清洗后才是有效内容; 不指定就只能退到「按 source 查不到就不清洗」。 `plugin.go:132` ### `PluginSDK.InjectText` ```go func (s *PluginSDK) InjectText(source, channel, text string) ``` InjectText injects a text message into the agent pipeline. 等价于 InjectTextOpts(..., InjectOptions{}):记入记忆、不裁剪。 `plugin.go:783` ### `PluginSDK.InjectTextNoMemory` ```go func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) ``` InjectTextNoMemory injects a text message without generating memory. 等价于 InjectTextOpts(..., InjectOptions{NoMemory: true})。 **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` | `plugin.go:789` ### `PluginSDK.InjectTextOpts` ```go func (s *PluginSDK) InjectTextOpts(source, channel, text string, opts InjectOptions) ``` InjectTextOpts 注入文本到 agent,并在这一次注入上声明记忆与裁剪行为。 `plugin.go:818` ### `PriorityL1` ```go const PriorityL1 ``` 中断优先级取值。 L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件, 如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力, 例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。 `plugin.go:161` ### `PriorityL2` ```go const PriorityL2 ``` 中断优先级取值。 L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件, 如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力, 例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。 `plugin.go:162` ### `PriorityL3` ```go const PriorityL3 ``` 中断优先级取值。 L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件, 如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力, 例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。 `plugin.go:163` ### `PriorityL4` !!! warning "仅内核内置插件可用" sdk/plugin.go:119-126 明确:L1..L3 任何插件可声明,L4 只有内核级插件可用,外部插件声明会被内核夹到 L3(内核侧有 priority_clamp_test.go 守着)。外部插件文档应说明「声明 L4 无效」。 ```go const PriorityL4 ``` PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。 `plugin.go:165` ### `RecallPolicyAuto` ```go const RecallPolicyAuto ``` 召回策略:决定一次工具调用/输入/注入是否据其内容**召回**(注入)相关记忆。 与 ContextPolicy **正交**:ContextPolicy 管「裁剪」(把低相关 L0 事件归档), RecallPolicy 管「召回」(把 L2/L3 的相关记忆注入本轮)。两者默认值刻意相反—— 裁剪是破坏性的,默认关(必须显式声明);召回是只读增量、日常对话本就需要, 默认 auto(输入/注入),仅**工具**默认 none(工具输出多为噪声,按需声明)。 `plugin.go:65` ### `RecallPolicyNone` ```go const RecallPolicyNone ``` 召回策略:决定一次工具调用/输入/注入是否据其内容**召回**(注入)相关记忆。 与 ContextPolicy **正交**:ContextPolicy 管「裁剪」(把低相关 L0 事件归档), RecallPolicy 管「召回」(把 L2/L3 的相关记忆注入本轮)。两者默认值刻意相反—— 裁剪是破坏性的,默认关(必须显式声明);召回是只读增量、日常对话本就需要, 默认 auto(输入/注入),仅**工具**默认 none(工具输出多为噪声,按需声明)。 `plugin.go:64` ### `PluginSDK.RegisterInputChannel` ```go func (s *PluginSDK) RegisterInputChannel(name string, def ChannelDef) error ``` RegisterInputChannel registers an input channel with its memory behavior. 契约:**凡是用 InjectText*/InjectInput*/InjectInterrupt*(source, "", ...) 注入的通道名,都应当在这里登记**。inputch 是内核里最基本的**输入路由单位**: 只有登记过的通道才能在 inputch 登记表里出现,父 agent 才能"把某个 inputch 划给驻留子"; 没登记就划分会直接失败(`inputch 未注册`)。 只登记输出通道(RegisterOutputChannel)而没登记输入通道时,内核会兜底登记同名 inputch 并打告警日志 —— 兜底只为兼容老插件,新插件请显式登记。 def.NoMemory: 此通道输入不参与记忆计算 def.Cleaner: 计算层对输入文本清洗后(不改原文)再向量化/提关键词 **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:52` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` | | [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:52` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` | | [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:209` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` | | [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:281` | `_ = s.RegisterInputChannel("calendar", sdk.ChannelDef{NoMemory: true})` | `plugin.go:665` ### `PluginSDK.RegisterOutputChannel` ```go func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error ``` RegisterOutputChannel registers an output channel that the output_send tool can route to. 与 RegisterInputChannel 的分工:本函数声明**出站**(output_send__ 的回复发给谁); 入站(谁会往 注入输入)是另一件事,用 RegisterInputChannel 声明。 若该通道同时也是你的注入入口,两个都要登记。 name: channel name (e.g. "qq", "webui")。 ❗**命名约束**:内核会把通道名拼进 LLM 的函数名(`output_send__`), 而上游对函数名的规范是 `^[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.) desc: description of the channel, expected meta format, and type enum def: 通道在记忆计算层的行为(NoMemory/Cleaner) handler: receives args map with keys: payload (string), type (string), meta (string|optional) **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:57` | `if err := s.RegisterOutputChannel(p.name, 1, "A2A Agent 互联通道(外部 agent 查询的回复由此返回)", sdk.ChannelDef{}, func(args…` | | [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:57` | `s.RegisterOutputChannel(p.name, 1, "ACP Agent 互联通道(外部 agent 会话的回复由此返回)", sdk.ChannelDef{}, func(args map[strin…` | | [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:411` | `s.RegisterOutputChannel("qq", sdk.CapText\|sdk.CapFile\|sdk.CapImage\|sdk.CapAudio,` | | [`weather`](../examples/index.md#weather) | `example/weather/plugin.go:104` | `if err := s.RegisterOutputChannel(tp+"weather_out", 0, "push weather to user", sdk.ChannelDef{` | `plugin.go:632` ### `PluginSDK.SetToolBlocks` ```go func (s *PluginSDK) SetToolBlocks(blocks []ContentBlock) ``` SetToolBlocks 在工具处理函数内注入多模态内容块,内核在下一条 tool message 的 content 数组里带上它们。需要「本轮就让模型看到」时用 InjectInputMedia。 `plugin.go:876` ### `ValidContextPolicy` ```go func ValidContextPolicy(policy string) bool ``` ValidContextPolicy 校验策略取值;空串等价于 ContextPolicyNone。 `plugin.go:49` ### `ValidRecallPolicy` ```go func ValidRecallPolicy(policy string) bool ``` ValidRecallPolicy 校验召回策略取值;空串按调用面取默认值。 `plugin.go:69` ## # 常量与枚举 SDK 里的取值枚举。其中带「仅内置」标注的取值在内核侧会被夹到较低级别。 ## StageOnInput 等 | 名称 | 说明 | |---|---| | `StageOnInput` | | | `StagePreAction` | | | `StagePostAction` | | | `StageBeforeToolcall` | | | `StageAfterToolcall` | | | `StageBeforeOutput` | | | `StageAfterOutput` | | ## ContextPolicyNone 等 | 名称 | 说明 | |---|---| | `ContextPolicyNone` | | | `ContextPolicyPrune` | | ## RecallPolicyNone 等 | 名称 | 说明 | |---|---| | `RecallPolicyNone` | | | `RecallPolicyAuto` | | ## ScenePolicyAuto 等 | 名称 | 说明 | |---|---| | `ScenePolicyAuto` | | | `ScenePolicyNone` | | ## PriorityL1 等 | 名称 | 说明 | |---|---| | `PriorityL1` | | | `PriorityL2` | | | `PriorityL3` | | | `PriorityL4` | PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。 | ## EventRawInput 等 | 名称 | 说明 | |---|---| | `EventRawInput` | | | `EventAgentOutput` | | | `EventAgentLLMChain` | | | `EventToolCall` | | | `EventReasoning` | | | `EventStage` | | | `EventSystem` | | | `EventReasoningDelta` | 流式增量事件(token 级):核心 process() 流式化后每收到一个增量块发布。 | | `EventContentDelta` | | ## StageScopeGlobal 等 | 名称 | 说明 | |---|---| | `StageScopeGlobal` | StageScopeGlobal receives all stage events (default). | | `StageScopeOwnTools` | StageScopeOwnTools only receives events for this plugin's own tool calls | ## CapText 等 | 名称 | 说明 | |---|---| | `CapText` | | | `CapFile` | | | `CapImage` | | | `CapAudio` | | | `CapStructured` | | ## ProxyAuthHomeAgent 等 | 名称 | 说明 | |---|---| | `ProxyAuthHomeAgent` | ProxyAuthHomeAgent 表示由 HomeAgent 统一保护:浏览器走门户会话 | | `ProxyAuthNone` | ProxyAuthNone 表示不经 HomeAgent 鉴权,直接把请求转发给上游。 | ## # 事件(Events) 订阅内核事件。**注意**:外部分布式插件的事件订阅不走 `Events()`(该接口在外部插件路径上未被注入,恒为 nil),而是由 `hmapdev` 生成的运行时通过 `events.subscribe` 完成。详见下方说明。 ## `EventSubscriber` EventSubscriber allows plugins to subscribe to kernel events. This is a restricted interface: plugins can subscribe but the kernel controls which events are delivered. | 方法 | 说明 | |---|---| | [`Subscribe`](#eventsubscribersubscribe) | | ### `EventSubscriber.Subscribe` !!! warning "仅内核内置插件可用" ```go Subscribe(eventType EventType, handler EventHandler) func() ``` `plugin.go:378` ### `Event` ```go type Event struct { Type EventType `json:"type"` Source string `json:"source"` Payload map[string]interface{} `json:"payload"` T … ``` Event represents a system event published by the kernel. `plugin.go:364` ### `EventHandler` ```go type EventHandler func(evt *Event) ``` EventHandler processes a system event. `plugin.go:372` ### `EventType` ```go type EventType string ``` EventType identifies the kind of system event. `plugin.go:346` ### `PluginSDK.Events` !!! warning "仅内核内置插件可用" 实测全仓 SetEventSubscriber 只有定义、无任何调用点(grep -rn SetEventSubscriber --include=*.go 仅命中 sdk/plugin.go 的定义与 lua_plugin.go 的一句注释)。外部插件拿到的 Events() 恒为 nil。外部插件订阅事件走的是桥接运行时的 events.subscribe RPC(proc_main.go.tmpl:1421 在插件侧分发、内核 protocol.go MethodEventsSubscribe),Lua 插件则走内部 SDK 的 Subscribe。这正是 PLUGIN_DEV.md 里没有说清的一处。 ```go func (s *PluginSDK) Events() EventSubscriber ``` Events returns the event subscriber for listening to kernel events (may be nil if not available). `plugin.go:550` ## # API 参考 本页所有内容**从源码生成**(`tools/apidoc`),签名与说明直接取自 `sdk/*.go` 的 文档注释。因此不存在「文档写了一套、代码是另一套」的情况——发现不一致时, 改的是源码注释,不是这里。 ## 怎么找 API 用上面的搜索框可以: - **按名称搜**:`InjectText`、`RegisterTool`、`memory.recall` - **按描述搜**:`注册工具`、`注入`、`重载`、`崩溃` - **按签名搜**:`(string) error`、`[]ContentBlock` - 带 仅内置 标记的条目在**外部插件里拿不到**,多数情况下你不需要它 ## 章节划分 按「你想做什么」组织,不是按 Go 的符号类别: | 章节 | 内容 | |---|---| | [工具(Tools)](tools.md) | 注册 LLM 可调用的工具——插件最常用的能力形态 | | [阶段钩子(Stages)](stages.md) | 在处理管道的固定点位插入逻辑 | | [记忆(Memory)](memory.md) | 三层记忆的读写:图 / 文档 / 文本,以及知识库 | | [输入/输出通道](channels.md) | 与外界交换消息,以及往流水线里注入内容 | | [配置(Settings)](settings.md) | 声明插件配置项,内核渲染到 WebUI | | [生命周期(Lifecycle)](lifecycle.md) | 启动、停止、卸载、自动重启 | | [事件(Events)](events.md) | 订阅内核事件 | | [LLM 调用](llm.md) | 插件主动调用模型 | | [常量与枚举](constants.md) | 取值枚举 | | [桥接装配点](bridge.md) | 由 `hmapdev` 生成的运行时调用,插件业务代码不碰 | | [仅内置插件可用](builtin-only.md) | 边界汇总——外部插件拿不到的 API 全在这里 | !!! tip "先看「能力边界」能省很多时间" 如果你正在设计插件,先读 [能力边界](../guide/capability-boundary.md): 它说明哪些能力外部插件有、哪些没有,以及**为什么**。 ## # 生命周期(Lifecycle) 插件的启动、停止与卸载回调。停止与卸载是两件事:**停止**是进程/加载状态变化,**卸载**(onRemove)是插件被删除前的清理机会。 ## `Plugin` Plugin is the interface every plugin must implement. | 方法 | 说明 | |---|---| | [`Name`](#pluginname) | | | [`Start`](#pluginstart) | | | [`Stop`](#pluginstop) | | ### `Plugin.Name` ```go Name() string ``` `plugin.go:14` ### `Plugin.Start` ```go Start(sdk *PluginSDK) error ``` `plugin.go:15` ### `Plugin.Stop` ```go Stop() error ``` `plugin.go:16` ## `PluginMgrAPI` PluginMgrAPI 提供插件管理能力(外部插件可调用)。 由 bridge 注入 dispatch 实现,走 C ABI CORE_PLUGIN_RELOAD_ONE 等。 | 方法 | 说明 | |---|---| | [`IsPluginDisabled`](#pluginmgrapiisplugindisabled) | IsPluginDisabled 查询插件是否被禁用。 | | [`ListLoadedPlugins`](#pluginmgrapilistloadedplugins) | ListLoadedPlugins 列出已加载插件。 | | [`ReloadOne`](#pluginmgrapireloadone) | ReloadOne 重载单个插件(停止后重新加载)。 | ### `PluginMgrAPI.IsPluginDisabled` ```go IsPluginDisabled(name string) bool ``` IsPluginDisabled 查询插件是否被禁用。 `plugin.go:389` ### `PluginMgrAPI.ListLoadedPlugins` ```go ListLoadedPlugins() []string ``` ListLoadedPlugins 列出已加载插件。 `plugin.go:387` ### `PluginMgrAPI.ReloadOne` ```go ReloadOne(name string) error ``` ReloadOne 重载单个插件(停止后重新加载)。 `plugin.go:385` ### `PluginSDK.AutoRestart` ```go func (s *PluginSDK) AutoRestart() bool ``` AutoRestart 返回插件是否允许自动重启。 `plugin.go:895` ### `PluginSDK.PluginMgr` ```go func (s *PluginSDK) PluginMgr() PluginMgrAPI ``` PluginMgr returns the plugin manager API (ReloadOne / ReloadPlugins / list). May be nil if the host did not wire it. `plugin.go:757` ### `PluginSDK.PluginName` ```go func (s *PluginSDK) PluginName() string ``` PluginName returns the name of the plugin. `plugin.go:501` ### `PluginSDK.RegisterOnRemoveHandler` ```go func (s *PluginSDK) RegisterOnRemoveHandler(fn func()) ``` RegisterOnRemoveHandler 注册插件被删除(卸载)时的清理回调。 注册的 handler 会在插件目录被移除前按"后注册先执行"的顺序调用, 适用于清理外部资源、删除配置表、下线状态等删除后处理。 可注册多个;执行后清空(一次删除只执行一次)。 **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:296` | `s.RegisterOnRemoveHandler(p.cleanupData)` | | [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:69` | `s.RegisterOnRemoveHandler(p.cleanupData)` | | [`rss`](../examples/index.md#rss) | `example/rss/plugin.go:127` | `s.RegisterOnRemoveHandler(p.cleanupData)` | `plugin.go:930` ### `PluginSDK.RegisterPluginAPI` ```go func (s *PluginSDK) RegisterPluginAPI(name string) error ``` RegisterPluginAPI registers this plugin's API for access by other plugins. `plugin.go:606` ### `PluginSDK.RegisterStopHandler` ```go func (s *PluginSDK) RegisterStopHandler(fn func()) ``` RegisterStopHandler 注册插件停止阶段的清理回调。 注册的 handler 会在插件 Stop() 之前按"后注册先执行"的顺序调用, 适用于释放资源、落盘状态、关闭子进程等停止时清理操作。 可注册多个;执行后清空(进程停止前只执行一次)。 **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:294` | `s.RegisterStopHandler(p.saveEvents)` | | [`deepsearch`](../examples/index.md#deepsearch) | `example/deepsearch/plugin.go:695` | `s.RegisterStopHandler(func() { p.shutdownSearxng() })` | `plugin.go:905` ### `PluginSDK.RunOnRemoveHandlers` ```go func (s *PluginSDK) RunOnRemoveHandlers() ``` RunOnRemoveHandlers 执行全部已注册的 onRemove handler(后注册先执行,执行后清空,幂等)。 由内核在卸载插件(registry.RemovePlugin)时、插件 Stop() 之后执行。 `plugin.go:941` ### `PluginSDK.RunStopHandlers` ```go func (s *PluginSDK) RunStopHandlers() ``` RunStopHandlers 执行全部已注册的 stop handler(后注册先执行,执行后清空,幂等)。 由内核(内置插件)或插件桥接层(外部插件 z_bridge 的 StopPlugin)在调用插件 Stop() 前执行。 `plugin.go:916` ### `PluginSDK.SetAutoRestart` ```go func (s *PluginSDK) SetAutoRestart(enabled bool) ``` SetAutoRestart 设置插件崩溃后内核是否自动重启它。 默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。 重启是有限度的:线性退避(第 n 次等 n×1s,即 1s→2s→3s), 且同一 5 分钟窗口内第 4 次崩溃就停下不再拉起(详见 README)。 注意这与「重载」(换 plugin.bin 后重新加载)是两回事。 **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:47` | `s.SetAutoRestart(true)` | | [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:47` | `s.SetAutoRestart(true)` | | [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:110` | `s.SetAutoRestart(true)` | | [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:63` | `s.SetAutoRestart(true)` | `plugin.go:888` ## # LLM 调用 让插件自己调用模型(而不是只等模型来调你)。 ## `LLMAPI` LLMAPI provides access to the LLM provider manager. | 方法 | 说明 | |---|---| | [`CurrentSource`](#llmapicurrentsource) | | | [`ListSources`](#llmapilistsources) | | | [`SetSource`](#llmapisetsource) | | ### `LLMAPI.CurrentSource` ```go CurrentSource() string ``` `llm.go:7` ### `LLMAPI.ListSources` ```go ListSources() []string ``` `llm.go:5` ### `LLMAPI.SetSource` ```go SetSource(name string) error ``` `llm.go:6` ### `PluginSDK.LLM` ```go func (s *PluginSDK) LLM() LLMAPI ``` LLM returns the LLM provider API (may be nil if not available). `plugin.go:536` ## # 记忆(Memory) 三层记忆的读写接口:**图记忆**(三元组关系)、**文档记忆**(带元数据的文档)、**文本记忆**(事件流水)。以及知识库。 ## `DocMemoryAPI` DocMemoryAPI provides access to the document vector store. | 方法 | 说明 | |---|---| | [`Insert`](#docmemoryapiinsert) | | | [`InsertWithMedia`](#docmemoryapiinsertwithmedia) | InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进 | | [`Query`](#docmemoryapiquery) | | | [`Remove`](#docmemoryapiremove) | | | [`Stats`](#docmemoryapistats) | | ### `DocMemoryAPI.Insert` ```go Insert(doc *Doc) error ``` `memory.go:76` ### `DocMemoryAPI.InsertWithMedia` ```go InsertWithMedia(doc *Doc, attachments []MediaAttachment) error ``` InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进 内容寻址存储(相同字节只存一份),只带 Digest 的直接引用已有内容。 媒体成为文档直接持有的一等记忆块:文档向量会融合它们的原生向量, 因此图片按自己的向量被召回,不依赖任何生成的描述文本。 `memory.go:81` ### `DocMemoryAPI.Query` ```go Query(text string, topK int) []*Doc ``` `memory.go:75` ### `DocMemoryAPI.Remove` ```go Remove(id string) ``` `memory.go:82` ### `DocMemoryAPI.Stats` ```go Stats() map[string]interface{} ``` `memory.go:83` ## `KnowledgeAPI` KnowledgeAPI provides access to the knowledge store. | 方法 | 说明 | |---|---| | [`Add`](#knowledgeapiadd) | | | [`List`](#knowledgeapilist) | | | [`Search`](#knowledgeapisearch) | | ### `KnowledgeAPI.Add` ```go Add(name, content string) error ``` `knowledge.go:6` ### `KnowledgeAPI.List` ```go List() ([]string, error) ``` `knowledge.go:7` ### `KnowledgeAPI.Search` ```go Search(query string, topK int) ([]*Knowledge, error) ``` `knowledge.go:5` ## `MemoryAPI` MemoryAPI provides access to the graph memory (entity-relation store). | 方法 | 说明 | |---|---| | [`Commit`](#memoryapicommit) | | | [`Introspect`](#memoryapiintrospect) | | | [`MergeEntities`](#memoryapimergeentities) | | | [`Purge`](#memoryapipurge) | | | [`Recall`](#memoryapirecall) | | ### `MemoryAPI.Commit` ```go Commit(triples []Triple) error ``` `memory.go:6` ### `MemoryAPI.Introspect` ```go Introspect() (map[string]interface{}, error) ``` `memory.go:7` ### `MemoryAPI.MergeEntities` ```go MergeEntities(source, target string) (int, error) ``` `memory.go:8` ### `MemoryAPI.Purge` ```go Purge(criteria map[string]string, mode string) (int, error) ``` `memory.go:9` ### `MemoryAPI.Recall` ```go Recall(query []string, depth int) ([]Entity, []Relation, error) ``` `memory.go:5` ## `SocialAPI` SocialAPI provides read-only access to the social graph (person profiles and relationships). External plugins can query person traits and social networks but cannot modify them. | 方法 | 说明 | |---|---| | [`GetNetwork`](#socialapigetnetwork) | | | [`GetPerson`](#socialapigetperson) | | | [`GetRelations`](#socialapigetrelations) | | | [`GetTrait`](#socialapigettrait) | | | [`ListPersons`](#socialapilistpersons) | | ### `SocialAPI.GetNetwork` ```go GetNetwork(name string, depth int) ([]*PersonProfile, error) ``` `memory.go:104` ### `SocialAPI.GetPerson` ```go GetPerson(name string) (*PersonProfile, error) ``` `memory.go:101` ### `SocialAPI.GetRelations` ```go GetRelations(name string) ([]SocialRelation, error) ``` `memory.go:103` ### `SocialAPI.GetTrait` ```go GetTrait(name, trait string) (string, bool) ``` `memory.go:102` ### `SocialAPI.ListPersons` ```go ListPersons() ([]string, error) ``` `memory.go:105` ## `TextMemoryAPI` TextMemoryAPI provides access to chronological text event storage. | 方法 | 说明 | |---|---| | [`Append`](#textmemoryapiappend) | | ### `TextMemoryAPI.Append` ```go Append(evt TextEvent) error ``` `memory.go:44` ### `Doc` ```go type Doc struct { ID string `json:"id"` Title string `json:"title"` Content string `json:"content"` Score … ``` Doc represents a document in the document store. MediaDigests / Attachments 在 Query 返回时由内核填充(仅元数据,不带字节)。 `memory.go:89` ### `PluginSDK.DocMemory` ```go func (s *PluginSDK) DocMemory() DocMemoryAPI ``` DocMemory returns the document memory API (may be nil if not available). `plugin.go:522` ### `Entity` ```go type Entity struct { Name string `json:"name"` Type string `json:"type"` MentionCount int `json:"mention_count"` } ``` Entity represents a named entity in the knowledge graph. `memory.go:13` ### `Knowledge` ```go type Knowledge struct { Name string `json:"name"` // Category 是该条目的父分类路径(如 "tech/go"),根下条目为空。 // // 为何加这个字段:对�� … ``` Knowledge represents a knowledge entry. **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`recoverydiag`](../examples/index.md#recoverydiag) | `example/recoverydiag/plugin.go:978` | `if p.sdk != nil && p.sdk.Knowledge() != nil {` | `knowledge.go:11` ### `PluginSDK.Knowledge` ```go func (s *PluginSDK) Knowledge() KnowledgeAPI ``` Knowledge returns the knowledge store API (may be nil if not available). **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`recoverydiag`](../examples/index.md#recoverydiag) | `example/recoverydiag/plugin.go:978` | `if p.sdk != nil && p.sdk.Knowledge() != nil {` | `plugin.go:529` ### `MediaAttachment` ```go type MediaAttachment struct { Digest string `json:"digest,omitempty"` MIME string `json:"mime,omitempty"` Data []byte `json:"data,omitempty"` Name string `json:"name,omite … ``` TextEvent represents a single text memory event. MediaAttachment 描述一份与记忆关联的媒体。 两个方向共用一个类型: - 写入(InsertWithMedia):给 Data + MIME 就是新内容;只给 Digest 则是引用已有内容。 - 读出(Query):内核只填 Digest/MIME,**不回 Data**—— 一次检索可能命中几十张图,把字节全塞回插件会把 ABI 消息撑爆。 需要字节时拿 Digest 单独取。 刻意没有 Description 字段:媒体不作为文本被索引,也不带任何生成的描述。 它只按自己的原生向量被检索与召回;附加文字请写在文档 / 三元组的文本里。 `memory.go:58` ### `PluginSDK.Memory` ```go func (s *PluginSDK) Memory() MemoryAPI ``` Memory returns the graph memory API (may be nil if not available). `plugin.go:508` ### `PersonProfile` ```go type PersonProfile struct { Name string `json:"name"` Traits map[string]string `json:"traits,omitempty"` Relations []SocialRelation `json:"relations,omitemp … ``` PersonProfile represents a person's complete profile (traits + social relations). `memory.go:109` ### `Relation` ```go type Relation struct { SourceName string `json:"source_name"` TargetName string `json:"target_name"` RelationType string `json:"relation_type"` Confidence float6 … ``` Relation represents a relationship between two entities. `memory.go:20` ### `PluginSDK.Social` ```go func (s *PluginSDK) Social() SocialAPI ``` Social returns the social graph API (may be nil if not available). `plugin.go:543` ### `SocialRelation` ```go type SocialRelation struct { Person string `json:"person"` Relation string `json:"relation"` } ``` SocialRelation represents a social relationship between two persons. `memory.go:116` ### `TextEvent` ```go type TextEvent struct { Role string `json:"role"` Content string `json:"content"` Timestamp int64 `json:"timestamp"` Channel … ``` `memory.go:65` ### `PluginSDK.TextMemory` ```go func (s *PluginSDK) TextMemory() TextMemoryAPI ``` TextMemory returns the text memory API (may be nil if not available). `plugin.go:515` ### `Triple` ```go type Triple struct { Subject string `json:"subject"` Relation string `json:"relation"` Object string `json:"object"` Confidence float64 `json:"c … ``` Triple represents a subject-relation-object triple for the knowledge graph. SentenceText 是这条三元组的原句,会写进 sentences 表;媒体引用挂在句子上, 所以 MediaDigests 非空时内核会保证句子存在(不给就自动合成一句)。 `memory.go:31` ## # 其他类型 剩余的类型与方法:`PluginSDK` 本体的访问器、`StageContext` 的并发控制,以及多模态辅助类型。没有归入上面任何一个主题,但可能仍会用到。 ## `DocMemoryAPI` DocMemoryAPI provides access to the document vector store. | 方法 | 说明 | |---|---| | [`Insert`](#docmemoryapiinsert) | | | [`InsertWithMedia`](#docmemoryapiinsertwithmedia) | InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进 | | [`Query`](#docmemoryapiquery) | | | [`Remove`](#docmemoryapiremove) | | | [`Stats`](#docmemoryapistats) | | ### `DocMemoryAPI.Insert` ```go Insert(doc *Doc) error ``` `memory.go:76` ### `DocMemoryAPI.InsertWithMedia` ```go InsertWithMedia(doc *Doc, attachments []MediaAttachment) error ``` InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进 内容寻址存储(相同字节只存一份),只带 Digest 的直接引用已有内容。 媒体成为文档直接持有的一等记忆块:文档向量会融合它们的原生向量, 因此图片按自己的向量被召回,不依赖任何生成的描述文本。 `memory.go:81` ### `DocMemoryAPI.Query` ```go Query(text string, topK int) []*Doc ``` `memory.go:75` ### `DocMemoryAPI.Remove` ```go Remove(id string) ``` `memory.go:82` ### `DocMemoryAPI.Stats` ```go Stats() map[string]interface{} ``` `memory.go:83` ## `EventSubscriber` EventSubscriber allows plugins to subscribe to kernel events. This is a restricted interface: plugins can subscribe but the kernel controls which events are delivered. | 方法 | 说明 | |---|---| | [`Subscribe`](#eventsubscribersubscribe) | | ### `EventSubscriber.Subscribe` !!! warning "仅内核内置插件可用" ```go Subscribe(eventType EventType, handler EventHandler) func() ``` `plugin.go:378` ## `IOInjector` IOInjector provides methods for injecting input and interrupts into the agent pipeline. All methods accept (source, channel) where channel is the target output channel for routing the agent's response. | 方法 | 说明 | |---|---| | [`InjectInputMedia`](#ioinjectorinjectinputmedia) | | | [`InjectInputMediaOpts`](#ioinjectorinjectinputmediaopts) | | | [`InjectInputMediaSync`](#ioinjectorinjectinputmediasync) | | | [`InjectInputMediaSyncOpts`](#ioinjectorinjectinputmediasyncopts) | | | [`InjectInputSync`](#ioinjectorinjectinputsync) | InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。 | | [`InjectInputSyncOpts`](#ioinjectorinjectinputsyncopts) | | | [`InjectInterruptMedia`](#ioinjectorinjectinterruptmedia) | | | [`InjectInterruptMediaOpts`](#ioinjectorinjectinterruptmediaopts) | | | [`InjectInterruptText`](#ioinjectorinjectinterrupttext) | | | [`InjectInterruptTextOpts`](#ioinjectorinjectinterrupttextopts) | | | [`InjectText`](#ioinjectorinjecttext) | | | [`InjectTextNoMemory`](#ioinjectorinjecttextnomemory) | | | [`InjectTextOpts`](#ioinjectorinjecttextopts) | 以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。 | | [`SetToolBlocks`](#ioinjectorsettoolblocks) | SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条 | ### `IOInjector.InjectInputMedia` ```go InjectInputMedia(source, channel, text string, blocks []ContentBlock) ``` `plugin.go:329` ### `IOInjector.InjectInputMediaOpts` ```go InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) ``` `plugin.go:340` ### `IOInjector.InjectInputMediaSync` ```go InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string ``` `plugin.go:330` ### `IOInjector.InjectInputMediaSyncOpts` ```go InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string ``` `plugin.go:341` ### `IOInjector.InjectInputSync` ```go InjectInputSync(source, channel, text string) string ``` InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。 用于通道消息的完整闭环:收到入站 → agent 处理 → 回复取回 → 送回通道。 **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` | | [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` | `plugin.go:325` ### `IOInjector.InjectInputSyncOpts` ```go InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string ``` `plugin.go:339` ### `IOInjector.InjectInterruptMedia` ```go InjectInterruptMedia(source, channel, text string, blocks []ContentBlock) ``` `plugin.go:331` ### `IOInjector.InjectInterruptMediaOpts` ```go InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) ``` `plugin.go:342` ### `IOInjector.InjectInterruptText` ```go InjectInterruptText(source, channel, text string) ``` `plugin.go:320` ### `IOInjector.InjectInterruptTextOpts` ```go InjectInterruptTextOpts(source, channel, text string, opts InjectOptions) ``` **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1319` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` | | [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:527` | `p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})` | | [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` | | [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` | `plugin.go:338` ### `IOInjector.InjectText` ```go InjectText(source, channel, text string) ``` `plugin.go:321` ### `IOInjector.InjectTextNoMemory` ```go InjectTextNoMemory(source, channel, text string) ``` **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` | `plugin.go:322` ### `IOInjector.InjectTextOpts` ```go InjectTextOpts(source, channel, text string, opts InjectOptions) ``` 以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。 上面那些不带 opts 的方法等价于传零值 InjectOptions(记入记忆 + 不裁剪), 保留它们是为了不破坏已有插件;新代码应当用 Opts 变体把意图写清楚。 `plugin.go:337` ### `IOInjector.SetToolBlocks` ```go SetToolBlocks(blocks []ContentBlock) ``` SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条 tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。 `plugin.go:328` ## `KnowledgeAPI` KnowledgeAPI provides access to the knowledge store. | 方法 | 说明 | |---|---| | [`Add`](#knowledgeapiadd) | | | [`List`](#knowledgeapilist) | | | [`Search`](#knowledgeapisearch) | | ### `KnowledgeAPI.Add` ```go Add(name, content string) error ``` `knowledge.go:6` ### `KnowledgeAPI.List` ```go List() ([]string, error) ``` `knowledge.go:7` ### `KnowledgeAPI.Search` ```go Search(query string, topK int) ([]*Knowledge, error) ``` `knowledge.go:5` ## `LLMAPI` LLMAPI provides access to the LLM provider manager. | 方法 | 说明 | |---|---| | [`CurrentSource`](#llmapicurrentsource) | | | [`ListSources`](#llmapilistsources) | | | [`SetSource`](#llmapisetsource) | | ### `LLMAPI.CurrentSource` ```go CurrentSource() string ``` `llm.go:7` ### `LLMAPI.ListSources` ```go ListSources() []string ``` `llm.go:5` ### `LLMAPI.SetSource` ```go SetSource(name string) error ``` `llm.go:6` ## `MemoryAPI` MemoryAPI provides access to the graph memory (entity-relation store). | 方法 | 说明 | |---|---| | [`Commit`](#memoryapicommit) | | | [`Introspect`](#memoryapiintrospect) | | | [`MergeEntities`](#memoryapimergeentities) | | | [`Purge`](#memoryapipurge) | | | [`Recall`](#memoryapirecall) | | ### `MemoryAPI.Commit` ```go Commit(triples []Triple) error ``` `memory.go:6` ### `MemoryAPI.Introspect` ```go Introspect() (map[string]interface{}, error) ``` `memory.go:7` ### `MemoryAPI.MergeEntities` ```go MergeEntities(source, target string) (int, error) ``` `memory.go:8` ### `MemoryAPI.Purge` ```go Purge(criteria map[string]string, mode string) (int, error) ``` `memory.go:9` ### `MemoryAPI.Recall` ```go Recall(query []string, depth int) ([]Entity, []Relation, error) ``` `memory.go:5` ## `Plugin` Plugin is the interface every plugin must implement. | 方法 | 说明 | |---|---| | [`Name`](#pluginname) | | | [`Start`](#pluginstart) | | | [`Stop`](#pluginstop) | | ### `Plugin.Name` ```go Name() string ``` `plugin.go:14` ### `Plugin.Start` ```go Start(sdk *PluginSDK) error ``` `plugin.go:15` ### `Plugin.Stop` ```go Stop() error ``` `plugin.go:16` ## `PluginMgrAPI` PluginMgrAPI 提供插件管理能力(外部插件可调用)。 由 bridge 注入 dispatch 实现,走 C ABI CORE_PLUGIN_RELOAD_ONE 等。 | 方法 | 说明 | |---|---| | [`IsPluginDisabled`](#pluginmgrapiisplugindisabled) | IsPluginDisabled 查询插件是否被禁用。 | | [`ListLoadedPlugins`](#pluginmgrapilistloadedplugins) | ListLoadedPlugins 列出已加载插件。 | | [`ReloadOne`](#pluginmgrapireloadone) | ReloadOne 重载单个插件(停止后重新加载)。 | ### `PluginMgrAPI.IsPluginDisabled` ```go IsPluginDisabled(name string) bool ``` IsPluginDisabled 查询插件是否被禁用。 `plugin.go:389` ### `PluginMgrAPI.ListLoadedPlugins` ```go ListLoadedPlugins() []string ``` ListLoadedPlugins 列出已加载插件。 `plugin.go:387` ### `PluginMgrAPI.ReloadOne` ```go ReloadOne(name string) error ``` ReloadOne 重载单个插件(停止后重新加载)。 `plugin.go:385` ## `SettingsAPI` | 方法 | 说明 | |---|---| | [`DataDir`](#settingsapidatadir) | DataDir returns the plugin-specific data directory (guaranteed to exist): | | [`Defs`](#settingsapidefs) | Defs returns config definitions matching the prefix. | | [`Dump`](#settingsapidump) | Dump returns all config values. | | [`Get`](#settingsapiget) | Get reads the plugin's own config value (config_ table). | | [`GetCore`](#settingsapigetcore) | GetCore reads the core config table. | | [`GetPlugin`](#settingsapigetplugin) | GetPlugin reads another plugin's config table. | | [`List`](#settingsapilist) | List returns all keys matching the given prefix. | | [`ListCore`](#settingsapilistcore) | ListCore lists core config keys matching the prefix. | | [`ListPlugin`](#settingsapilistplugin) | ListPlugin lists another plugin's config keys matching the prefix. | | [`Plugins`](#settingsapiplugins) | Plugins returns a list of all plugin config namespaces. | | [`RegisterDef`](#settingsapiregisterdef) | RegisterDef registers a config definition for UI display. | | [`Set`](#settingsapiset) | Set writes a config value to the plugin's own config table. | | [`SetCore`](#settingsapisetcore) | SetCore writes to the core config table. | | [`SetPlugin`](#settingsapisetplugin) | SetPlugin writes to another plugin's config table. | ### `SettingsAPI.DataDir` ```go DataDir() string ``` DataDir returns the plugin-specific data directory (guaranteed to exist): /plugin_data/. Plugins should persist any runtime files (generated images, caches, downloads) here. `settings.go:25` ### `SettingsAPI.Defs` ```go Defs(prefix string) []*ConfigDef ``` Defs returns config definitions matching the prefix. `settings.go:40` ### `SettingsAPI.Dump` ```go Dump() map[string]interface{} ``` Dump returns all config values. `settings.go:43` ### `SettingsAPI.Get` ```go Get(key string) (interface{}, error) ``` Get reads the plugin's own config value (config_ table). `settings.go:5` ### `SettingsAPI.GetCore` ```go GetCore(key string) (interface{}, error) ``` GetCore reads the core config table. `settings.go:14` ### `SettingsAPI.GetPlugin` ```go GetPlugin(plugin, key string) (interface{}, error) ``` GetPlugin reads another plugin's config table. `settings.go:28` ### `SettingsAPI.List` ```go List(prefix string) ([]string, error) ``` List returns all keys matching the given prefix. `settings.go:11` ### `SettingsAPI.ListCore` ```go ListCore(prefix string) ([]string, error) ``` ListCore lists core config keys matching the prefix. `settings.go:20` ### `SettingsAPI.ListPlugin` ```go ListPlugin(plugin, prefix string) ([]string, error) ``` ListPlugin lists another plugin's config keys matching the prefix. `settings.go:34` ### `SettingsAPI.Plugins` ```go Plugins() []string ``` Plugins returns a list of all plugin config namespaces. `settings.go:46` ### `SettingsAPI.RegisterDef` ```go RegisterDef(def ConfigDef) ``` RegisterDef registers a config definition for UI display. `settings.go:37` ### `SettingsAPI.Set` ```go Set(key string, value interface{}) error ``` Set writes a config value to the plugin's own config table. `settings.go:8` ### `SettingsAPI.SetCore` ```go SetCore(key string, value interface{}) error ``` SetCore writes to the core config table. `settings.go:17` ### `SettingsAPI.SetPlugin` ```go SetPlugin(plugin, key string, value interface{}) error ``` SetPlugin writes to another plugin's config table. `settings.go:31` ## `SocialAPI` SocialAPI provides read-only access to the social graph (person profiles and relationships). External plugins can query person traits and social networks but cannot modify them. | 方法 | 说明 | |---|---| | [`GetNetwork`](#socialapigetnetwork) | | | [`GetPerson`](#socialapigetperson) | | | [`GetRelations`](#socialapigetrelations) | | | [`GetTrait`](#socialapigettrait) | | | [`ListPersons`](#socialapilistpersons) | | ### `SocialAPI.GetNetwork` ```go GetNetwork(name string, depth int) ([]*PersonProfile, error) ``` `memory.go:104` ### `SocialAPI.GetPerson` ```go GetPerson(name string) (*PersonProfile, error) ``` `memory.go:101` ### `SocialAPI.GetRelations` ```go GetRelations(name string) ([]SocialRelation, error) ``` `memory.go:103` ### `SocialAPI.GetTrait` ```go GetTrait(name, trait string) (string, bool) ``` `memory.go:102` ### `SocialAPI.ListPersons` ```go ListPersons() ([]string, error) ``` `memory.go:105` ## `TextMemoryAPI` TextMemoryAPI provides access to chronological text event storage. | 方法 | 说明 | |---|---| | [`Append`](#textmemoryapiappend) | | ### `TextMemoryAPI.Append` ```go Append(evt TextEvent) error ``` `memory.go:44` ### `AudioURL` ```go type AudioURL struct { URL string `json:"url"` } ``` `plugin.go:966` ### `EffectiveProxyAuth` ```go func EffectiveProxyAuth(auth string) string ``` EffectiveProxyAuth 返回生效的鉴权模式(空串归一化为 ProxyAuthHomeAgent)。 `proxy.go:169` ### `ToolError.Error` ```go func (e *ToolError) Error() string ``` Error 实现 error,便于工具同时走 (ToolError, error) 通道。 **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:317` | `http.Error(w, "query/message.text required", http.StatusBadRequest)` | | [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:175` | `http.Error(w, "", http.StatusMethodNotAllowed)` | | [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:259` | `return map[string]interface{}{"isError": true, "content": "Request failed: " + err.Error()}, nil` | | [`browser`](../examples/index.md#browser) | `example/browser/plugin_test.go:15` | `if err == nil \|\| !strings.Contains(err.Error(), "timeout is required") {` | `plugin.go:264` ### `ImageURL` ```go type ImageURL struct { URL string `json:"url"` Detail string `json:"detail,omitempty"` } ``` `plugin.go:961` ### `MemItem` ```go type MemItem struct { Role string `json:"role"` Content string `json:"content"` Score float64 `json:"score"` } ``` MemItem represents a memory item in stage context. `plugin.go:220` ### `NormalizeProxyHost` ```go func NormalizeProxyHost(pluginName string) string ``` NormalizeProxyHost 由插件名派生默认 Host 标签。 下划线转连字符:插件名允许下划线(huawei_smarthome),但 DNS label 不允许, 直接用会导致该子域名无法解析——这里统一转换,避免每个插件各自碰运气。 `proxy.go:202` ### `PluginSDK` ```go type PluginSDK struct { name string regTool ToolRegistrar regStage StageRegistrar regAPI APIRegistrar regOutput OutputChannelRegistrar … ``` PluginSDK is the main API surface provided to plugins at runtime. It wraps tool registration, settings, memory, knowledge, LLM, and IO injection. `plugin.go:436` ### `ProxyAuthHomeAgent` ```go const ProxyAuthHomeAgent ``` ProxyAuthHomeAgent 表示由 HomeAgent 统一保护:浏览器走门户会话 (homeagent_session cookie),非浏览器客户端走 X-API-Key。 两者都没有时返回 401,而不是把请求透传给上游。 `proxy.go:149` ### `ProxyAuthNone` ```go const ProxyAuthNone ``` ProxyAuthNone 表示不经 HomeAgent 鉴权,直接把请求转发给上游。 适用场景:上游自己有鉴权且调用方不是浏览器(设备/嵌入式客户端), 或上游是刻意公开的服务。选用它意味着**信任上游自身的鉴权**, 且该服务在网络层可达范围内对所有人开放。 `proxy.go:156` ### `ProxyDef` ```go type ProxyDef struct { // Name 是同一插件内多条声明的唯一标识(如 "ui"、"api")。 // 运行期由 RegisterProxy 的第一个参数填入;声明式由 … ``` 反向代理声明:插件告诉 HomeAgent「我起了个 HTTP 服务,请把它反代出去」。 为什么需要:插件自带 Web UI / HTTP API 时,监听地址在插件自己的配置里 (如 127.0.0.1:12100),外部无从得知;而 webui 的对外端口通常只有一个 (默认 :8080,且常经 frp 单端口隧道穿透)。没有声明机制时,用户只能 「知道端口 + 自己配转发」,插件换端口就失效。 设计取舍——**声明式而非注册式**:声明写在 plugin.json 里,由 HomeAgent 在加载插件时读取聚合,而不是让插件在运行期调 API 注册。理由: 1. 静态可发现:未启动/已崩溃的插件,其服务声明依然可见(可给出准确报错 「插件 X 声明了 ui 但目标 127.0.0.1:12100 不可达」,而不是静默 404); 2. 可版本化:声明随插件包一起分发、可 diff、可审计; 3. 旧内核无害:manifest 解析忽略未知字段,未支持该能力的 HomeAgent 读旧 插件、或旧 HomeAgent 读新插件都不会报错。 与 ToolDef / ChannelDef / ConfigDef 同族:SDK 定义声明契约,内核实现行为。 声明方式与其它能力一致 —— 在 Start() 里调 RegisterProxy(name, def), 或写进 plugin.json 的 proxies 字段(外部插件两种都支持)。 安全性:**不声明 = 不被反代**。声明本身就是能力声明,因此不需要在 capabilities 里另外开一个开关——最小权限默认生效。 # 单一入口原则(强制要求) **一个声明 = 一个入口**。被反代的插件必须让它的全部资源与接口都能从 该入口的一个基准路径出发访问到,不得依赖「入口之外的根路径」。 为什么强制:反代有两种挂载形态,而它们对「根路径」的处理截然不同—— Host 形态(host):插件独占 <标签>.<基域名>,根路径就是插件的根。 根绝对路径(fetch('/api/x'))**天然正确**。 Path 形态(path):插件挂在门户自身 host 的某个前缀下,根路径属于**门户**。 此时插件里的 fetch('/api/x') 会打到门户自己的 /api/x —— 静默错路由,页面能开但功能全坏。 于是「同一个插件必须同时支持两种形态」这条要求,等价于: **插件内部一律使用相对路径**(或基于 /location 推导的路径), 绝不硬编码以 / 开头的绝对路径。 这样同一份前端在两种形态下都正确,插件作者也不必知道自己被挂在哪。 反代层据此可以:外部子域可用时给 Host 形态,子域不可用(证书/放行限制) 时给 Path 形态,**无需插件配合改动**。 自检(插件作者在本地就该做):把页面挂到 <门户>/<任意前缀>/ 下访问, 所有请求都必须仍然打到插件自己。 本项目实测案例:某插件前端写死 fetch('/api/status'),配在 /p/huawei/ 下会打到门户的 /api/status(404 或返回门户数据); 改成相对路径后两种形态同时可用。 ProxyDef 是一个服务的**反代声明体**。 与 ToolDef 同构:Name 同时出现在字段与 RegisterProxy 的第一个参数里 (ToolDef 也是这么做的 —— 字段供 plugin.json 序列化,参数供运行期调用)。 Name 只用于展示、日志与冲突提示,**不参与路由**(路由键是 Host 与 Path)。 `proxy.go:64` ### `ProxyRegistrar` ```go type ProxyRegistrar func(name string, def ProxyDef) ``` ProxyRegistrar 由内核注入(与 ToolRegistrar / InputChannelRegistrar 同族)。 插件不直接调它,用 RegisterProxy。 为什么需要运行期注册(明明有 plugin.json 自动发现):**内置插件** (编译进内核、没有独立插件目录与 plugin.json,如 remotedevice)扫不到; 而它们恰恰最需要被反代出去(设备网关就是内置的)。两种来源互补: - 外部插件 → plugin.json 的 proxies(静态,未启动也可见) - 内置插件 → RegisterProxy(运行期,随 Start 注册) `proxy.go:306` ### `PluginSDK.RegisterProxy` ```go func (s *PluginSDK) RegisterProxy(name string, def ProxyDef) ``` RegisterProxy 声明一个需要 HomeAgent 反代出去的服务。 与 RegisterTool / RegisterInputChannel / RegisterOutputChannel 同一风格: 显式给名字 + 声明体。名字用于展示、日志与冲突提示(不参与路由 —— 路由键是 def.Host / def.Path)。 用法(通常在 Start 里调用): s.RegisterProxy("ui", sdk.ProxyDef{ Host: "myapp", Target: "127.0.0.1:12100", }) 声明立即生效(反代表在下一次请求时重建)。**不做去重**:同一 Host/Path 被两条声明占用时由反代层判定冲突并明确报错,而不是在这里静默吞掉 —— 插件作者需要看见冲突。 与 plugin.json 的 proxies 字段等价:写哪个都行,两者会合并(同名以本调用为准)。 `proxy.go:335` ### `SDKVersion` ```go var SDKVersion ``` SDKVersion 是对外暴露的 SDK 版本号。 `plugin.go:10` ### `ScenePolicyAuto` ```go const ScenePolicyAuto ``` 场面策略:决定一次输入是否参与**场面识别**(场景式记忆)。 与前两项再正交一轴:NoMemory 管「进不进记忆计算」、ContextPolicy 管 「裁不裁上下文」、RecallPolicy 管「召不召回记忆」,本项管的是 「这条输入算不算一场戏的一部分」——它决定输入会不会产出现场指纹 (通道/对话对象/工具/话题/时段),进而决定会不会长出、命中、写入场景。 默认(空串或 ScenePolicyAuto)**参与**,保持既有行为:场景式记忆自 v1.3 落地起就对所有通道无条件生效,没有开关。不默认关有两个原因: 1. 场景只**附加**现有记忆的检索路,不改记忆本体,默认关会让存量 通道突然失去场景召回; 2. 「关」是少数意图(内部信噪通道),少数意图不该是默认—— 与 ContextPolicy 刻意相反(同为破坏性操作,那里是默认关)。 该关的典型是纯内部通道:system(内核自循环)、kernel、timer、healthcheck。 但**现网不标任何一个**(2026-09-26 裁定):实测这些 0-refs 通道合计 70 strength、0 条记忆,场景召回返回空;而 declared 场景不进相似度空间 (loadEmergentScenesLocked 只取 origin='emergent'),多写对聚类零影响。 「多写无影响、少写会缺场景」——默认 auto 保持开,声明项只作为插件 将来确实需要时的闸门。 `plugin.go:98` ### `ScenePolicyNone` ```go const ScenePolicyNone ``` 场面策略:决定一次输入是否参与**场面识别**(场景式记忆)。 与前两项再正交一轴:NoMemory 管「进不进记忆计算」、ContextPolicy 管 「裁不裁上下文」、RecallPolicy 管「召不召回记忆」,本项管的是 「这条输入算不算一场戏的一部分」——它决定输入会不会产出现场指纹 (通道/对话对象/工具/话题/时段),进而决定会不会长出、命中、写入场景。 默认(空串或 ScenePolicyAuto)**参与**,保持既有行为:场景式记忆自 v1.3 落地起就对所有通道无条件生效,没有开关。不默认关有两个原因: 1. 场景只**附加**现有记忆的检索路,不改记忆本体,默认关会让存量 通道突然失去场景召回; 2. 「关」是少数意图(内部信噪通道),少数意图不该是默认—— 与 ContextPolicy 刻意相反(同为破坏性操作,那里是默认关)。 该关的典型是纯内部通道:system(内核自循环)、kernel、timer、healthcheck。 但**现网不标任何一个**(2026-09-26 裁定):实测这些 0-refs 通道合计 70 strength、0 条记忆,场景召回返回空;而 declared 场景不进相似度空间 (loadEmergentScenesLocked 只取 origin='emergent'),多写对聚类零影响。 「多写无影响、少写会缺场景」——默认 auto 保持开,声明项只作为插件 将来确实需要时的闸门。 `plugin.go:99` ### `PluginSDK.SetProxyRegistrar` ```go func (s *PluginSDK) SetProxyRegistrar(r ProxyRegistrar) ``` SetProxyRegistrar 由内核注入。插件不直接调它(与 SetInputChannelRegistrar 同族)。 `proxy.go:309` ### `ToolError` ```go type ToolError struct { // Field 是出错的参数字段名(参数校验失败时填)。 Field string `json:"field,omitempty"` // Reason 是机器可读的原因码: … ``` ToolError 描述一次工具调用的失败原因。 存在的理由:失败若只表达为文本,模型无法定位到字段,只能原样重试 (实测 cmd_run 失败率 34%~48%,全部源于同一个成因:参数被截断或 JSON 写坏,工具却只回报 "command is required" 这类与真因无关的错)。 ⚠️ 零值语义:插件**不必**改用本类型。内核的失败识别同时兼容既有三种约定 ({"error":…}、{"isError":true,…}、显式 error 返回),见 core.isToolError。 本类型是给**新写**的工具用的可选项,不是迁移要求。 `plugin.go:252` ### `PluginSDK.UnregisterOutputChannel` !!! warning "仅内核内置插件可用" 外部插件的桥接模板只注入 SetOutputChannelRegistrar(proc_main.go.tmpl:693-705),**未**注入 SetOutputChannelUnregistrar(grep Unregistrar 在 templates/ 下无命中)。因此外部插件的 regOutputUnreg 为 nil,UnregisterOutputChannel 会命中 `if reg != nil` 的 else 分支**直接返回 nil**(sdk/plugin.go:539-549)——即**静默无效**:不报错、通道也没注销。内置插件由 internal/sdk/plugin.go:330 注入 cfg.RegOutputUnreg,真正生效。外部插件要让通道下线,只能重载插件。 ```go func (s *PluginSDK) UnregisterOutputChannel(name string) error ``` UnregisterOutputChannel 注销一个输出通道(动态通道随资源生灭时必须调用)。 `plugin.go:643` ### `ValidProxyAuth` ```go func ValidProxyAuth(auth string) bool ``` ValidProxyAuth 校验 Auth 取值;空串合法(等价 ProxyAuthHomeAgent)。 `proxy.go:160` ### `ValidProxyHostLabel` ```go func ValidProxyHostLabel(label string) bool ``` ValidProxyHostLabel 校验子域名标签是否合法(DNS label 规则)。 独立成导出函数:插件作者在写声明时、HomeAgent 在加载时、工具链在打包时 都要用同一套规则判定,避免三处各写一份而互相不一致。 `proxy.go:180` ### `ValidScenePolicy` ```go func ValidScenePolicy(policy string) bool ``` ValidScenePolicy 校验场面策略取值;空串等价于 ScenePolicyAuto。 `plugin.go:103` ### `ValidateProxyDef` ```go func ValidateProxyDef(d ProxyDef) string ``` ValidateProxyDef 校验一条反代声明,返回人类可读的错误说明(合法时为空)。 为什么要在 SDK 里做校验:HomeAgent 加载插件时必须能明确拒绝坏声明并说明 原因(而不是静默忽略导致用户以为配好了);插件作者也需要在本地就能查出 拼错的 Target/Host。同一套规则两端共用。 `proxy.go:229` ## # 配置(Settings) 声明插件自己的配置项,内核会把它渲染到 WebUI 的设置页,并为每个插件维护独立的配置表。 ## `SettingsAPI` | 方法 | 说明 | |---|---| | [`DataDir`](#settingsapidatadir) | DataDir returns the plugin-specific data directory (guaranteed to exist): | | [`Defs`](#settingsapidefs) | Defs returns config definitions matching the prefix. | | [`Dump`](#settingsapidump) | Dump returns all config values. | | [`Get`](#settingsapiget) | Get reads the plugin's own config value (config_ table). | | [`GetCore`](#settingsapigetcore) | GetCore reads the core config table. | | [`GetPlugin`](#settingsapigetplugin) | GetPlugin reads another plugin's config table. | | [`List`](#settingsapilist) | List returns all keys matching the given prefix. | | [`ListCore`](#settingsapilistcore) | ListCore lists core config keys matching the prefix. | | [`ListPlugin`](#settingsapilistplugin) | ListPlugin lists another plugin's config keys matching the prefix. | | [`Plugins`](#settingsapiplugins) | Plugins returns a list of all plugin config namespaces. | | [`RegisterDef`](#settingsapiregisterdef) | RegisterDef registers a config definition for UI display. | | [`Set`](#settingsapiset) | Set writes a config value to the plugin's own config table. | | [`SetCore`](#settingsapisetcore) | SetCore writes to the core config table. | | [`SetPlugin`](#settingsapisetplugin) | SetPlugin writes to another plugin's config table. | ### `SettingsAPI.DataDir` ```go DataDir() string ``` DataDir returns the plugin-specific data directory (guaranteed to exist): /plugin_data/. Plugins should persist any runtime files (generated images, caches, downloads) here. `settings.go:25` ### `SettingsAPI.Defs` ```go Defs(prefix string) []*ConfigDef ``` Defs returns config definitions matching the prefix. `settings.go:40` ### `SettingsAPI.Dump` ```go Dump() map[string]interface{} ``` Dump returns all config values. `settings.go:43` ### `SettingsAPI.Get` ```go Get(key string) (interface{}, error) ``` Get reads the plugin's own config value (config_ table). `settings.go:5` ### `SettingsAPI.GetCore` ```go GetCore(key string) (interface{}, error) ``` GetCore reads the core config table. `settings.go:14` ### `SettingsAPI.GetPlugin` ```go GetPlugin(plugin, key string) (interface{}, error) ``` GetPlugin reads another plugin's config table. `settings.go:28` ### `SettingsAPI.List` ```go List(prefix string) ([]string, error) ``` List returns all keys matching the given prefix. `settings.go:11` ### `SettingsAPI.ListCore` ```go ListCore(prefix string) ([]string, error) ``` ListCore lists core config keys matching the prefix. `settings.go:20` ### `SettingsAPI.ListPlugin` ```go ListPlugin(plugin, prefix string) ([]string, error) ``` ListPlugin lists another plugin's config keys matching the prefix. `settings.go:34` ### `SettingsAPI.Plugins` ```go Plugins() []string ``` Plugins returns a list of all plugin config namespaces. `settings.go:46` ### `SettingsAPI.RegisterDef` ```go RegisterDef(def ConfigDef) ``` RegisterDef registers a config definition for UI display. `settings.go:37` ### `SettingsAPI.Set` ```go Set(key string, value interface{}) error ``` Set writes a config value to the plugin's own config table. `settings.go:8` ### `SettingsAPI.SetCore` ```go SetCore(key string, value interface{}) error ``` SetCore writes to the core config table. `settings.go:17` ### `SettingsAPI.SetPlugin` ```go SetPlugin(plugin, key string, value interface{}) error ``` SetPlugin writes to another plugin's config table. `settings.go:31` ### `ConfigDef` ```go type ConfigDef struct { Key string `json:"key"` Default interface{} `json:"default,omitempty"` Type string `json:"type"` DisplayName string … ``` ConfigDef describes a configuration field for the WebUI. `settings.go:50` ### `PluginSDK.Settings` ```go func (s *PluginSDK) Settings() SettingsAPI ``` Settings returns the settings API for reading/writing plugin configuration. sett 在 New 时一次性写入且无 setter,故不需要加锁。 **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:68` | `s.Settings().RegisterDef(sdk.ConfigDef{` | | [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:63` | `s.Settings().RegisterDef(sdk.ConfigDef{` | | [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:114` | `s.Settings().RegisterDef(sdk.ConfigDef{` | | [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:67` | `s.Settings().RegisterDef(sdk.ConfigDef{` | `plugin.go:505` ## # 阶段钩子(Stages) 在消息处理管道的固定点位插入自己的逻辑。阶段比工具更底层:工具是模型主动调用的,阶段是流程经过时必然触发的。 ### `StageContext.IsResponded` ```go func (c *StageContext) IsResponded() bool ``` `plugin.go:213` ### `StageContext.Lock` ```go func (c *StageContext) Lock() ``` **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:158` | `p.sessMu.Lock()` | | [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:128` | `p.srvMu.Lock()` | | [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:37` | `p.runMu.Lock()` | | [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:427` | `p.mu.Lock()` | `plugin.go:211` ### `StageContext.RLock` ```go func (c *StageContext) RLock() ``` **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:267` | `p.mu.RLock()` | | [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:649` | `p.mu.RLock()` | | [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:224` | `p.mu.RLock()` | | [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1152` | `ctx.RLock()` | `plugin.go:209` ### `StageContext.RUnlock` ```go func (c *StageContext) RUnlock() ``` **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:273` | `p.mu.RUnlock()` | | [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:650` | `defer p.mu.RUnlock()` | | [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:229` | `p.mu.RUnlock()` | | [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1155` | `ctx.RUnlock()` | `plugin.go:210` ### `PluginSDK.RegisterStage` ```go func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler, scope ...StageScope) ``` RegisterStage registers a handler for a pipeline stage. scope: StageScopeGlobal (default) — receives all stage events. StageScopeOwnTools — only before_toolcall/after_toolcall for this plugin's tools. **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:152` | `s.RegisterStage(sdk.StagePreAction, p.stagePreAction)` | | [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:719` | `s.RegisterStage(sdk.StageOnInput, p.onInputAuthContext, sdk.StageScopeGlobal)` | | [`sanitizer`](../examples/index.md#sanitizer) | `example/sanitizer/plugin.go:52` | `s.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {` | | [`weather`](../examples/index.md#weather) | `example/weather/plugin.go:94` | `s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {` | `plugin.go:571` ### `Stage` ```go type Stage string ``` Stage represents a point in the message processing pipeline. `plugin.go:26` ### `StageContext` ```go type StageContext struct { mu sync.RWMutex RawMessage string UserID string GroupID string ContextMsgs []map[string]interface{} L … ``` StageContext provides context for stage handlers. `plugin.go:189` ### `StageHandler` ```go type StageHandler func(ctx *StageContext) error ``` StageHandler is a function that handles a pipeline stage event. `plugin.go:23` ### `StageRegistrar` ```go type StageRegistrar func(stage Stage, handler StageHandler) ``` StageRegistrar registers a stage handler. `plugin.go:407` ### `StageScope` ```go type StageScope int ``` StageScope controls which events a stage handler receives. `plugin.go:393` ### `StageContext.Unlock` ```go func (c *StageContext) Unlock() ``` **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:164` | `p.sessMu.Unlock()` | | [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:129` | `defer p.srvMu.Unlock()` | | [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:40` | `p.runMu.Unlock()` | | [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:432` | `p.mu.Unlock()` | `plugin.go:212` ## # 工具(Tools) 注册 LLM 可调用的工具。工具是插件最主要的能力形态:模型看到 `ToolDef` 的说明后决定是否调用,调用时执行你的 `ToolHandler`。 ### `ContentBlock` ```go type ContentBlock struct { Type string `json:"type"` Text string `json:"text,omitempty"` ImageURL *ImageURL `json:"image_url,omitempty"` AudioURL *AudioURL `jso … ``` ContentBlock 是多模态内容块(OpenAI 格式:text/image_url/audio_url)。 插件工具返回结果时可用 PluginSDK.SetToolBlocks 注入,让下一轮 LLM 请求在 tool message 的 content 数组里带上图片/音频,实现"模型看图/听音频"。 `plugin.go:954` ### `PluginSDK.RegisterTool` ```go func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error ``` RegisterTool registers a tool that the LLM can call. **示例插件里的真实用法** | 插件 | 位置 | 代码 | |---|---|---| | [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:76` | `s.RegisterTool(tp+"a2a_query", sdk.ToolDef{` | | [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:70` | `s.RegisterTool(tp+"acp_query", sdk.ToolDef{` | | [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:159` | `s.RegisterTool(tp+"generate", sdk.ToolDef{` | | [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:85` | `s.RegisterTool(tp+"video", sdk.ToolDef{` | `plugin.go:557` ### `ToolCall` ```go type ToolCall struct { ID string `json:"id"` Name string `json:"name"` Plugin string `json:"plugin,omitempty … ``` ToolCall represents a model's request to call a tool. `plugin.go:227` ### `ToolDef` ```go type ToolDef struct { Name string `json:"name"` Plugin string `json:"plugin,omitempty"` Description string … ``` ToolDef describes a tool that the plugin exposes. `plugin.go:279` ### `ToolHandler` ```go type ToolHandler func(args map[string]interface{}) (interface{}, error) ``` ToolHandler is a function that handles a tool call. `plugin.go:20` ### `ToolResult` ```go type ToolResult struct { CallID string `json:"call_id"` Name string `json:"name"` Plugin string `json:"plugin,omitempty"` Success bool `json:"suc … ``` ToolResult represents the result of a tool call. `plugin.go:235` ## # 示例插件 SDK 仓 `example/` 下有多个**真实可编译**的示例插件,覆盖工具注册、通道、记忆读写、LLM 调用、生命周期等常见形态。 每个示例都能用 `hmapdev build` 打成 `.hmap` 装进内核直接跑。 ## `a2a` 用到的 API:`Error` · `InjectInputSync` · `Lock` · `RegisterInputChannel` · `RegisterOutputChannel` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock` ## `acp` 用到的 API:`Error` · `InjectInputSync` · `Lock` · `RLock` · `RUnlock` · `RegisterInputChannel` · `RegisterOutputChannel` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock` ## `ai_image` 用到的 API:`Error` · `RegisterTool` · `SetAutoRestart` · `Settings` ## `bili` 用到的 API:`Lock` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock` ## `browser` 用到的 API:`Error` · `InjectInterruptTextOpts` · `InjectTextNoMemory` · `Lock` · `RegisterInputChannel` · `Unlock` ## `calendar` 用到的 API:`InjectInterruptTextOpts` · `RLock` · `RUnlock` · `RegisterInputChannel` · `RegisterOnRemoveHandler` · `RegisterStopHandler` ## `deepsearch` 用到的 API:`RegisterStopHandler` ## `memo` 用到的 API:`InjectInterruptTextOpts` · `RLock` · `RUnlock` · `RegisterOnRemoveHandler` · `RegisterStage` ## `qq` 用到的 API:`InjectInterruptTextOpts` · `RLock` · `RUnlock` · `RegisterOutputChannel` · `RegisterStage` ## `recoverydiag` 用到的 API:`Knowledge` ## `rss` 用到的 API:`RegisterOnRemoveHandler` ## `sanitizer` 用到的 API:`RegisterStage` ## `weather` 用到的 API:`RegisterOutputChannel` · `RegisterStage` ## # 能力边界:哪些 API 外部插件能用 HomeAgent 有两类插件: | 类型 | 说明 | 分发 | |---|---|---| | **外部插件** | 第三方开发,编译成 `.hmap` 后安装 | 独立分发,**可闭源** | | **内置插件** | 编译进内核,`init()` 自注册 | 随内核发行,需合入主仓 | SDK 包是**同一个** `gitcode.com/JianFeeeee/homeagent-sdk/sdk`,但两类插件拿到的 **能力不同**:外部插件跑在独立进程里,由内核通过桥接注入能力(IPC,不是共享内存里的直接调用)。 本页说明边界在哪、为什么,以及**怎么在写代码前就知道某个 API 是否可用**。 ## 一句话规则 > **公开 SDK 包里声明的符号,不等于外部插件拿得到。** 原因是:有些能力只有进程内的内置插件才可能拥有(比如直接读事件发布通道、 直接注入到内核 IO 层)。外部插件通过桥接运行时拿到的是一份**受注入的能力集合**。 ## 外部插件**不可用**的 API 这些 API 在公开包里存在,但在外部插件路径上拿不到。文档里每条都带 仅内置 标记, 完整清单见 [仅内置插件可用](../api/builtin-only.md)。 | API | 外部插件的实际情况 | 该用什么 | |---|---|---| | `sdk.PluginSDK.Events()` | **恒为 nil**。桥接运行时不注入 event subscriber(`SetEventSubscriber` 全仓无调用点) | 桥接运行时已按你的声明完成 `events.subscribe`;Lua 插件用 `sdk.events.subscribe` | | `sdk.PluginSDK.SetEventSubscriber` | 无人调用 | 同上 | | `UnregisterOutputChannel` | 桥接只注入 registrar、**不注入 unregistrar**,调用是**静默无效**(返回 nil,不报错也不注销) | `RegisterOutputChannel` 可用;注销需重载插件 | | `SocialAPI` 的写操作 | 公开接口只有 6 个**只读**方法 | 读用 `s.GetPerson` 等;写需内置插件 | | `EventSubscriber.Publish` | 公开接口**刻意只有 Subscribe**,没有 Publish | 只订阅 | | `PriorityL4` | 声明会被内核**夹到 L3** | 用 L1–L3 | | `RegisterChannel` / `ListChannels` / `OutputChan` / `InjectInput` / `InjectInterrupt` | 只存在于内核内部 SDK | `RegisterInputChannel` / `RegisterOutputChannel` / `InjectText` 等公开方法 | | `PluginMgr()` 的完整能力 | 公开 `PluginMgrAPI` **只有 3 个方法**(`ReloadOne` / `ListLoadedPlugins` / `IsPluginDisabled`) | 就这 3 个;`ReloadPlugins`/`Disable`/`Remove` 属内部接口 | !!! warning "两处常见的文档错误(本站已更正)" 1. **`PluginMgr()` 不是「仅内置可用」**。桥接模板显式注入了它 (`base.SetPluginMgrAPI(procPluginMgr{})`),公开 `PluginMgrAPI` 也注明 「外部插件可调用」。真正的区别是**方法数量**:公开面 3 个,内部面 9 个。 容易混淆是因为两个包里有**同名但不同**的接口: `sdk.PluginMgrAPI`(3 方法)与 `internal/sdk.PluginManager`(9 方法)。 2. **`Events()` 恒为 nil 这件事以前没写清**。旧文档把 `Events()` 当作 可用的订阅入口,但桥接运行时不注入 subscriber。外部插件的事件订阅 实际由生成的运行时通过 `events.subscribe` 完成。 ## 判定依据来自哪里 本站的「仅内置」标记不是猜的,逐条来自: 1. **`tools/hmapdev/templates/proc_main.go.tmpl`** —— 外部插件运行时**实际注入** 哪些能力,看 `buildPluginSDK()` 里的 `base.Set*` 调用。 2. **`internal/sdk`** —— 内置插件用的完整接口,与公开包对照。 3. **内核 RPC 协议表**(`internal/plugin/proc/protocol.go`)—— 外部插件**能发哪些请求**。 每条裁定的具体依据写在该 API 的告警框里,可以直接核对。 ## 怎么快速确认 - 用 [API 搜索](../api/index.md) 搜 API 名或功能描述,带 仅内置 的就是外部不可用 - 直接看 [仅内置插件可用](../api/builtin-only.md) 汇总页 - 拿不准时,**读 `example/` 下的示例插件** —— 它们全是外部插件, 能被它们编译通过的写法,外部就一定可用 ## 为什么这样设计 不是为了限制,而是**IPC 边界决定了能力边界**:外部插件跑在独立进程里, 内核只能通过显式的注入点把能力交过去。凡是需要「持有内核内部数据结构」 的能力(事件发布通道、IO 通道、插件注册表全量操作),进程外都无法安全暴露。 这套边界同时带来好处:插件崩溃不会带崩内核(进程隔离), 以及**插件可以闭源**(SDK 是 MIT,见[首页](../index.md#_3))。 ## # 第一个 Lua 插件 Lua 插件适合**轻量、快速原型**:不需要 Go 编译环境,改完重启内核即可生效。 但它有一个必须理解的限制 —— 执行模型是**被动回调**。 ## 执行模型(先读这段) Lua 插件跑在内核进程内的 gopher-lua 解释器里(单 Lua 状态 + 互斥锁): - **被动回调**:`main.lua` 只在加载时执行一次。此后工具、阶段钩子、 输入输出通道全部由内核事件驱动回调你的 Lua 函数。**插件不能自己启动后台任务。** - **没有并发**:Lua 侧没有 goroutine、协程调度,也没有 `os` / `io` 库和 socket 监听。 唯一主动出站通道是 `sdk.http.get/post`(同步请求)。 - **任何阻塞循环都会持锁卡死该插件的全部调用。** !!! warning "要常驻服务就用 Go 插件" 需要监听端口、后台轮询、定时任务的,请用 [Go 插件](first-plugin.md) (可自行启动 goroutine)。Lua 侧的等价做法是**事件驱动**:把逻辑挂在 工具、阶段钩子或通道回调上。 ## 生成工程 ```bash hmapdev init myluaplugin --lua cd myluaplugin ``` 结构: ``` myluaplugin/ ├── plg.json — entry: "main.lua", targets: "lua" ├── main.lua — 插件实现 ├── sdk.lua — SDK 模拟层(支持独立测试) └── README.md ``` ## 一个完整的插件 ```lua -- main.lua local plugin = { name = "myluaplugin" } function plugin.start(sdk) sdk.log("info", "myluaplugin starting...") sdk.register_tool("myluaplugin_hello", { description = "向指定的人打招呼", parameters = { type = "object", properties = { who = { type = "string", description = "要打招呼的对象" } }, required = { "who" } } }, function(args) return { content = "hello, " .. (args.who or "world") .. "!" } end) sdk.log("info", "myluaplugin started") end function plugin.stop() sdk.log("info", "myluaplugin stopped") end return plugin ``` ## 本地测试 `sdk.lua` 是纯 Lua 的 SDK 模拟实现,可以直接用解释器跑: ```bash lua main.lua # [lua-plugin] info: myluaplugin starting... # [lua-plugin] register_tool: myluaplugin_hello # [lua-plugin] info: myluaplugin started ``` 在内核里运行时,`sdk.*` 由 Go 层注入,`sdk.lua` 里所有 `-- !impl` 标记的函数 会被替换成真实实现。 ## API 约定的两点 - **注册类函数调用即时报错**(抛 Lua error)—— 注册失败不会静默。 - **数据类函数统一返回 `(result, err)`**,`err` 为 nil 表示成功。 核心未装配的子系统(如 SocialAPI)返回空值而非报错。 Lua 侧的 `sdk.*` 能力与外部 Go 插件对齐至 SDK 1.3.0(需内核 1.4.0+)。 !!! note "历史提醒" 1.1–1.3 期间,媒体 / 注入标志位 / 优先级能力只在 Go 侧有,Lua 侧静默缺失。 现已全量对齐,并由 `internal/plugin/lua_surface_test.go` 的契约测试守住 「`sdk.lua` 承诺的每个函数都有运行时绑定」。 ## 构建 ```bash hmapdev build # → dist/myluaplugin_lua.hmap ``` Lua 插件直接打包源码,不经过编译。 ## 下一步 - [能力边界](capability-boundary.md) —— Lua 与 Go 外部插件的能力面一致 - [打包与发布](packaging.md) - [示例](../examples/index.md) —— `example/luademo` 是 Lua 版参考实现 ## # 第一个 Go 插件 以下是一个**能直接跑起来**的最小插件:注册一个工具、声明一项配置、处理停止与卸载。 ## 1. 生成工程 ```bash hmapdev init myplugin cd myplugin ``` 生成的结构: ``` myplugin/ ├── plg.json — 插件元信息(名称、版本、入口、目标平台) ├── plugin.go — 插件实现 ├── go.mod — 模块定义 ├── README.md └── thirdpart/ — 外部源码存放目录(可选) ``` `hmapdev build` 时会在构建目录自动生成子进程运行时(`z_proc_gen.go` 等), **不需要手工创建,也不要提交**。 ## 2. 插件实现 插件的全部契约是一个 `Plugin` 接口([API 参考](../api/lifecycle.md#plugin)): | 方法 | 何时调用 | |---|---| | `Name() string` | 内核需要标识这个插件时 | | `Start(*sdk.PluginSDK) error` | 插件加载后。**在这里注册工具、通道、配置** | | `Stop() error` | 插件停止时(重载、禁用、内核退出都会触发) | 再加一个工厂函数。**名字必须是 `NewPluginFactory`** —— 生成的运行时按这个名字调用: ```go func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { return &Plugin{name: name}, nil } ``` !!! warning "不要写成 `NewPlugin`" 生成的子进程运行时调用的入口是 `NewPluginFactory`。仓库里有 3 个早期示例 同时保留了两个名字(`NewPlugin` 只是遗留别名),但新插件只写 `NewPluginFactory` 即可。写错名字的后果是**编译能过、加载时找不到入口**。 ## 3. 一个完整的例子 这是一个「打招呼」工具,带一项配置: ```go package main import ( "fmt" "gitcode.com/JianFeeeee/homeagent-sdk/sdk" ) type Plugin struct { name string sdk *sdk.PluginSDK } func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { p.sdk = s // ① 声明配置项:内核会把它渲染到 WebUI 设置页 s.Settings().RegisterDef(sdk.ConfigDef{ Key: "plugin.myplugin.greeting", Default: "hello", Type: "string", DisplayName: "问候语", Description: "打招呼时使用的前缀", Category: "myplugin", }) // ② 注册工具:模型看到 Description 后决定是否调用 tp := p.name + "_" s.RegisterTool(tp+"hello", sdk.ToolDef{ Name: tp + "hello", Description: "向指定的人打招呼", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "who": map[string]interface{}{ "type": "string", "description": "要打招呼的对象", }, }, "required": []string{"who"}, }, }, p.handleHello) // ③ 卸载(插件被删除)前清理自己产生的数据。 // 注意与 Stop 的区别:Stop 在每次重载时也会触发。 s.RegisterOnRemoveHandler(func() { fmt.Printf("[%s] 清理数据\n", p.name) }) return nil } func (p *Plugin) Stop() error { return nil } func (p *Plugin) handleHello(args map[string]interface{}) (interface{}, error) { who, _ := args["who"].(string) greeting := "hello" if v, err := p.sdk.Settings().Get("plugin.myplugin.greeting"); err == nil && v != "" { greeting = v } return map[string]interface{}{ "content": fmt.Sprintf("%s, %s!", greeting, who), }, nil } func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { return &Plugin{name: name}, nil } ``` ## 4. 工具返回值的两条约定 `ToolHandler` 返回 `(interface{}, error)`,模型侧看到的是一条 tool message: - **正常结果**:返回一个 map,把要展示给模型的文本放在 `content` 字段。 未识别的字段也会一并传给模型,可以放结构化数据。 - **业务失败**:返回 `map[string]interface{}{"isError": true, "content": "原因"}` **并返回 nil error**。这样模型能看到失败原因并自行调整; 若返回 Go 的 `error`,那是**工具调用本身出错**,语义不同。 ```go func errorResult(msg string) map[string]interface{} { return map[string]interface{}{"isError": true, "content": msg} } ``` ## 5. 构建与安装 ```bash hmapdev build # 默认产出多平台 bundle # → dist/myplugin_bundle.hmap hmapdev build --no-bundle # 只构建当前平台 # → dist/myplugin_linux_amd64.hmap ``` 安装到内核:在 WebUI 的插件管理页上传 `.hmap`,或从 URL / 本地路径安装。 详见 [打包与发布](packaging.md)。 ## 下一步 - [能力边界](capability-boundary.md) —— 哪些 API 外部插件能用 - [工具(Tools)](../api/tools.md) —— `ToolDef` 的完整字段 - [记忆(Memory)](../api/memory.md) —— 让插件读写长期记忆 - [示例插件](../examples/index.md) —— `example/memo` 是个完整的可读实现 ## # 环境与工具链 `hmapdev` 是 SDK 仓提供的统一插件开发工具链,Go 与 Lua 两种插件都用它, 最终产出 `.hmap` 插件包(工具名即取自这个包格式)。 !!! note "改名说明" 1.2.0 起工具链由 `plugindev` 更名为 `hmapdev`;SDK 存储目录同时由 `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk` (旧目录会自动继续沿用)。 ## 安装 从源码构建: ```bash git clone https://github.com/JianFeeeee/homeagentsdk cd homeagentsdk/tools/hmapdev go build -o hmapdev # 把 hmapdev 放进 PATH,或直接用 ./hmapdev ``` 也可以从 SDK 的 release 附件下载预编译二进制(`hmapdev_linux_amd64` 等, 共 5 个平台:linux/darwin/windows × amd64/arm64)。 > 仓已迁到 GitHub;gitcode 仅作国内镜像(源码同步,**release 附件暂时仍在那里**): > `https://gitcode.com/JianFeeeee/homeagent-sdk/releases`。 > Go 模块路径仍是 `gitcode.com/JianFeeeee/homeagent-sdk` —— 这是有意保留的, > 改模块路径会让现有插件的 `go.mod` 全面失效。 ## SDK 版本管理 `hmapdev` 会维护一份本地 SDK 存储,`init` 时按 `plg.json` 里的 `sdk` 字段 选择版本。两者**必须**一致,否则编译出的插件与内核协议可能错配。 ```bash hmapdev sdk list # 已安装的 SDK 版本 hmapdev sdk current # 当前使用的版本 hmapdev sdk latest # 最新可用版本 hmapdev sdk install v1.2.0 # 安装指定版本 hmapdev sdk use v1.2.0 # 切换版本 hmapdev sdk path # 当前 SDK 路径 ``` 存储在 `~/.homeagent/hmapdev/sdk//`。 !!! warning "版本未命中会**明确报错**" `plg.json` 声明的 `sdk` 版本若不在本地存储里,`hmapdev` 不会退回某个默认版本, 而是报错并让你先 `hmapdev sdk install`。这是有意的:静默降级会产出与内核 协议不匹配的插件,那种失败要到运行时才暴露。 ## 源码调试 不编译直接跑插件源码,输出调用轨迹: ```bash hmapdev debug [dir] # dir 默认当前目录 ``` 写 Lua 插件时更简单——`sdk.lua` 是 SDK 模拟层,可以直接用解释器跑: ```bash lua main.lua ``` ## 下一步 - [第一个 Go 插件](first-plugin.md) - [第一个 Lua 插件](first-lua-plugin.md) ## # 多平台构建 ## 默认就是多平台 `hmapdev build` 默认 bundle 模式,一次产出含三个平台的单个 `.hmap`: ``` dist/myplugin_bundle.hmap └── plugin.bin.linux.amd64 └── plugin.bin.darwin.amd64 └── plugin.bin.windows.amd64 ``` 安装时内核挑当前平台那份,重命名为 `plugin.bin`。 ## 逐平台构建 ```bash hmapdev build --no-bundle # 按 plg.json 的 targets 构建 hmapdev build --target linux/arm64 # 追加一个目标 ``` `plg.json` 里声明目标: ```json { "name": "myplugin", "version": "1.0.0", "targets": "linux/amd64,windows/amd64" } ``` 单平台输出文件名:`{name}_{os}_{arch}.hmap`。 ## 交叉编译 子进程插件**不再需要 cgo**,所以交叉编译不需要目标平台的 C 工具链 —— 这是 v1.0.0 的收益之一。 !!! note "bundle 模式忽略 `targets`" 固定构建 linux/amd64、darwin/amd64、windows/amd64。如果你只需要其中一个, 用 `--no-bundle` 更快。 ## 平台能力差异 历史上有过一处真实的平台断层,现已消除: - **v1.0.0 之前**:Windows 上插件只看到 **3 个 stage 字段、且无法写回**。 - **v1.0.0 起**:Windows 与其他平台**共用同一套 RPC 实现**,16 字段全可见 + 写回。 因此**不必**为 Windows 写条件分支 —— 除非你的插件自己用了平台专有的外部命令。 ## 下一步 - [打包与发布](packaging.md) - [环境与工具链](getting-started.md) ## # 打包与发布 `hmapdev build` 一次完成编译与打包,产出 `.hmap` 分发包(zip 格式,内含 `plugin.json` 清单 + 二进制)。 ## 命令 ```bash hmapdev build # 默认 bundle(多平台合集) hmapdev build --no-bundle # 只构建 plg.json targets 里的平台 hmapdev build --target linux/arm64 # 在 targets 基础上追加目标 hmapdev build --outdir out # 指定输出目录(默认 dist) hmapdev build --sdk-path # 覆盖 go.mod 的 replace 指向的 SDK hmapdev build --replace # 追加 go.mod replace(可多次) ``` 执行流程: 1. 读 `plg.json` 的 `targets` / `bundle` 决定构建目标 2. 生成子进程运行时代码(`z_proc_gen.go`、`z_proc_shm_*.go`) 3. **Go 插件**:`go build`(普通可执行文件,`CGO_ENABLED=0`) **Lua 插件**:直接打包源码,不编译 4. 生成 `plugin.json` 输出清单 5. 打成 `.hmap` ## 两个 JSON 的区别 这一点经常混淆: | 文件 | 谁维护 | 作用 | 关键字段 | |---|---|---|---| | `plg.json` | **你** | 项目元信息,构建输入 | `targets`、`bundle` | | `plugin.json` | `hmapdev` 自动生成 | 构建产物清单 | `entry`、`platforms` | `plg.json` 里的 `sdk` 字段声明**本插件针对的 SDK 版本**;未命中本地 SDK 存储 会明确报错(见[环境与工具链](getting-started.md))。 ## 多平台(bundle) `build` 默认就是 bundle 模式:一次编译 linux/amd64、darwin/amd64、windows/amd64, 产出一个含全部平台二进制的 `.hmap`;安装时内核挑当前平台那份。 ```bash hmapdev build # → dist/myplugin_bundle.hmap hmapdev build --no-bundle # → dist/myplugin_linux_amd64.hmap 等 ``` !!! note "bundle 模式会忽略 `plg.json` 的 `targets`" 固定构建上述三个平台。交叉编译需要对应工具链(如 Linux 上构建 darwin 需要 clang / macOS SDK),缺工具链时会失败 —— 此时用 `--no-bundle` 只构建当前平台。 bundle 包内按 `plugin.bin..` 区分,安装时重命名为 `plugin.bin`。 ## 产物形态 子进程插件是**普通可执行文件**,不分平台后缀: | 平台 | 二进制 | |---|---| | Linux / macOS / Windows | `plugin.bin` | !!! warning "v1.0.0 破坏性变更:不再加载 `.so` / `.dll`" 外部插件从 C ABI 动态库改为**子进程 + 共享内存**。 - `plugin.so` / `plugin.dylib` / `plugin.dll` **不再被加载**。 新内核遇到旧产物会跳过并报可操作错误,不崩溃。 - **业务代码不用改一行** —— 公开 SDK 接口零改动,用新版 `hmapdev` (原 `plugindev`)重编即可。 - `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写 `plugin.so` 也无妨), 现在只用于区分 Lua 插件。 - 产物不再需要 cgo,交叉编译无需目标平台 C 工具链。 ## 安装 三种方式(`9876` 是 pluginmgr 的本地端口,默认只监听 `127.0.0.1`、无鉴权): ```bash # 从 URL 安装(仅 http/https,流式下载不落盘) curl -X POST http://127.0.0.1:9876/plugins \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/myplugin.hmap"}' # 从本地路径安装(读取文件,不移动原文件) curl -X POST http://127.0.0.1:9876/plugins \ -H "Content-Type: application/json" \ -d '{"path": "/path/to/myplugin.hmap"}' # 直接上传二进制 curl -X POST http://127.0.0.1:9876/plugins \ --data-binary @dist/myplugin_bundle.hmap ``` 安装后调用 `/api/v1/plugins/reload` 或重启内核生效。 走 WebUI 的 HTTP API(默认 `8080`,需 `api_key` 鉴权,内部代理到 pluginmgr): ```bash curl -X POST http://127.0.0.1:8080/api/v1/plugins \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"path": "/path/to/myplugin.hmap"}' ``` 也可以在 WebUI 的插件管理页面上传。 ## 发布前自查 - [ ] `plg.json` 的 `sdk` 版本与目标内核匹配 - [ ] `version` 已递增(内核按版本判断是否需要重装) - [ ] 若插件有外部状态,`SetAutoRestart(false)` 或在 `Start` 里重建连接 (崩溃重启是**线性退避** 1s→2s→3s,5 分钟内第 4 次崩溃即停止, 见[生命周期](../api/lifecycle.md#pluginsdksetautorestart)) - [ ] `RegisterOnRemoveHandler` 里清理自己写下的数据文件 - [ ] 在 `-race` 下跑一遍:插件的 `Start` 与工具的并发访问是最常见的竞态来源 ## # 工具并发声明:`ParallelSafe` / `Serial` > 对应 `sdk.ToolDef` 的两个字段。内核在**同一轮**收到多个 `tool_call` 时, > 依据它们决定并发还是整批串行。 > > 状态:已随 2026-09 的并行内核落地并在生产启用。 ## 1. 为什么是"保守 opt-in" **默认整批串行。** 只有当**批内每一个**工具都显式声明 `ParallelSafe: true` 时,那一批才并发;**只要有一个不声明,整批退回串行**。 这不是"漏了声明导致退化"的将就,而是刻意的设计: - 存量插件**不改一行**就得到保守行为(整批串行),不会被升级意外并发 - 声明是**责任**而非特权 —— 声明者必须自己确认线程安全 - 宁可慢,不可错:一次错误的并发可能让两个工具抢同一个 SQLite 写、 同一台设备、或同一个输出通道 ```go // 同批全是安全工具 → 并发 tools: [a(ParallelSafe), b(ParallelSafe)] ⇒ 并发 // 只要有一个没声明 → 整批串行 tools: [a(ParallelSafe), b(默认)] ⇒ 串行 ``` ## 2. 三个条件都满足才可以声明 `ParallelSafe` 1. **handler 自身线程安全** —— 不持有跨调用的可变状态 2. **不与同批其它工具争抢同一资源** —— SQLite 写、设备、同一输出通道 3. **执行顺序无关** —— 顺序敏感的工具应留 `false`,由内核保序 第 3 条常被忽略:内核能保证**用户可见的消息**按声明顺序落盘,但**工具 之间的实际执行先后**在并发模式下不确定。有顺序依赖就留 `false`。 ## 3. `Serial`:显式的反向标记 ```go Serial bool `json:"serial,omitempty"` ``` `ParallelSafe` 的零值 `false` 已经表达"串行",插件**无法区分**: - "我没想过" - "我确认过**必须**串行,且有原因" 一旦工具作者需要把"这里**故意**串行,是有原因的"写进代码(而不只是没填), 这个区分就是必需的 —— 否则只能靠命名约定传递意图。 适用场景:读操作但有隐含顺序约束(终端 `read`/`resize` 这类共享会话 状态);写操作虽已加锁但需要串行以获得可预测的交错顺序。 **优先级:`Serial` 胜出。** 即使同时写了 `ParallelSafe: true`,`Serial` 仍然生效 —— 显式声明"必须串行"不允许被 `ParallelSafe` 或任何默认值覆盖。 ## 4. 写法 声明字段放在 `ToolDef` 结构体的**末尾**,遵循既有 `NoMemory` 的风格: ```go sdk.RegisterTool(sdk.ToolDef{ Name: "my_readonly_query", Description: "……", Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}, Handler: h.query, ParallelSafe: true, // 声明在末尾 }, s) ``` 需要"故意串行"时: ```go sdk.RegisterTool(sdk.ToolDef{ Name: "my_terminal_input", Handler: h.input, Serial: true, // 胜出,忽略 ParallelSafe }, s) ``` ## 5. 内置工具 核心仓的内置工具用 `toolDefOptions` / `parallelOpts()` 声明 (`internal/agent/core/tooldefs.go`),最终由 `buildToolDefs` 汇总成 `ToolDef.ParallelSafe`。 内核只提供并行调度基础设施,**不硬编码任何工具名的安全状态表** —— 状态由每个工具在自己的声明结构里给出,查询时走聚合表。 ## 6. 效果(实测) 同批 N 个各约 250ms 的工具,新版内核"内部并发"对"强制串行": | N | 加速比 | | --- | --- | | 2 | ~1.41× | | 4 | ~2.22× | | 8 | ~3.88× | 批耗时几乎不随 N 增长,串行批严格线性。 > 压测时**必须同时记录实际执行的工具数**,不能只看耗时。 > 某次对照中旧版本耗时更短、但因适配器缺 `stream_index` 导致 > **实际处理 0 个工具** —— 那不是性能提升,是全失败。 ## 相关 设计背景与踩坑见**核心仓**文档(不在本仓): - `docs/zh/toolcall-contract-and-sequence-design.md` —— 契约与序列设计 - `docs/zh/toolcall-parallel-execution-plan.md` —— 阶段、实测压测数据 - `docs/zh/deploy-runbook.md` —— 生产部署(含适配器 `stream_index` 相关) ## # 场景记忆(Scene Memory) > 场景式记忆是内核 v1.3 起的能力。它不新增 API 面,只影响**你的输入被怎样记住与取回**。 > 与 `NoMemory` / `ContextPolicy` / `RecallPolicy` 并列为第四项声明:`ScenePolicy`。 ## 它解决什么问题 三层记忆按**字面相关性**召回:你得说出相近的词,记忆才会被取回来。 场景记忆补上另一半:按**场合**召回。 同一场合再次出现时,当时挂在这个场合上的约定、偏好、人物关系会自动回来—— 与这次说了什么措辞无关。 ``` 你:以后在群里回消息简短点 └─ 这条记忆挂到场面「chan:qq + peer:group_xxx」上 一周后,同一个群里有人问「上次说的格式是什么」 └─ 场面重现(还没等你提到「格式」),那条约定已经被取回 ``` ## 场面是自己长出来的 场景**不需要声明**。每轮交互,内核采集一组可观察信号当这轮���「场面指纹」: | 特征 | 来源 | 权重 | 说明 | |---|---|---|---| | `chan` | 输入通道名 | 1.0 | 最强的同一性信号 | | `peer` / `peer_group` | 注入点给的 `payload` 里的 `group_id`/`user_id`/`chat_id` 等 | 1.0 | 群与私聊分开,避免互相命中 | | `tool` | 触发这一步的工具名 | 0.8 | 行为信号 | | `topic` | 清洗后输入的内容词 | 0.4 | 软信号,同场面的不同话题不该被拆开 | | `part` | 时段(夜间/上午/下午/晚间) | 0.2 | 最弱,只做辅助 | 指纹反复重合时,一场场面就成形了。相似度按**加权 Jaccard** 算 (共享特征的权重和 ÷ 并集的权重和)——不加权的话,一次偶然的话题重合 会把两个不同场面并成一个。 **同类场面出现第二次才被认定。** 一次性的交互不建场面: 那不是「场面」,建了只会让图库被一次性事件撑满。 ## 声明你的参与姿态 ```go sdk.ChannelDef{ ScenePolicy: sdk.ScenePolicyNone, // 这条通道不参与场面识别 } ``` 或单次注入覆盖: ```go sdk.InjectOptions{ ScenePolicy: sdk.ScenePolicyNone, } ``` | 取值 | 含义 | |---|---| | `""`(空)/ `ScenePolicyAuto` | **参与**(默认,保持既有行为) | | `ScenePolicyNone` | **不参与**:不产任何场面指纹,也不派生场景键 | **默认是参与而不是不参与**,与 `ContextPolicy` 刻意相反。原因是场景只 **附加**检索路径、不改记忆本体,默认关会让存量通道突然失去场景召回; 而「关」是少数意图(纯内部信号)。 声明 `none` 之后连时段特征都不产——一个不参与的门面不该在场面索引里 留下任何足迹。 ### 谁该考虑关掉 内核自循环(`system`)、心跳(`timer`)、内部状态汇报(`kernel`)这类 纯内部信号。它们每次触发都在撑一个场面,会把不相干的交互聚到一起。 反过来说,**多标一个通道通常没有代价**:一个没人往上面写记忆的场面, 召回时返回空。关不关都不影响正确性——所以拿不准时,默认参与就好。 ## 怎么给场面命名 场景键有两种来源: **通道派生(默认)**——`evt.Source` 派生出 `chan:qq` 这类键。你不用管。 **显式声明(进阶)**——在注入时给出更有语义的键: ```go p.sdk.InjectInterruptTextOpts("qq", "qq", text, sdk.InjectOptions{ ScenePolicy: sdk.ScenePolicyAuto, }) ``` 也可以通过 `payload["scene"]` 传层级键(支持 `string` / `[]string` / `[]interface{}` 三种形态): ```go "chan:qq/peer:group_1027" ``` 召回走**前缀匹配**(`chan:qq` 能覆盖 `chan:qq/peer:xxx`),用 `/` 兜底 以免 `chan:qq` 误吞 `chan:qq2` 这种同前缀但不同层的场景。 ## 场面记忆不改变什么 - **不改记忆本体**:场景是记忆的**附加索引**,删掉场景不删记忆。 - **不让模型负责**:`memory_commit` 的 `scene` 留空即可,内核会挂到本轮 解析出的场面上。留空是安全的一侧——猜错的场面会把无关记忆钉死。 - **不影响同步通道**:`webui` / `cli` / 终端走 `ResponseCh`,不经 `output_send__*`,与场面无关。 ## 相关 API - `ChannelDef.ScenePolicy` —— 通道级声明(见 [输入/输出通道](../api/channels.md)) - `InjectOptions.ScenePolicy` —— 单次注入覆盖(见 [其他类型](../api/misc.md)) - `ScenePolicyAuto` / `ScenePolicyNone` / `ValidScenePolicy` —— 常量与校验 ## # 受限 SDK 与安全 外部插件与内置插件的区别不只是「能不能调某个函数」,还包含一层**安全边界**: 外部插件的进程不共享内核地址空间,能力通过显式注入点交过去。 ## 三层隔离 | 层 | 机制 | 防住了什么 | |---|---|---| | **进程** | 插件跑在独立子进程 | 插件 panic / 内存越界**不会带崩内核** | | **能力** | 只注入显式声明的接口 | 插件拿不到未授权的内核内部结构 | | **权限** | 公开接口是内部接口的**只读子集** | 插件无法改写他人数据 | 第一种是 v1.0.0 从 C ABI 动态库改为子进程 + 共享内存的直接收益: 在此之前,插件 panic 会带崩 `homed`。 ## 受限接口是怎么实现的 **按接口裁剪,而不是按方法裁剪。** 同一个概念在公开包与内部包里是**两个不同的 接口声明**,公开的那个只保留安全子集: ```go // 公开 SDK:6 个只读方法 type SocialAPI interface { GetPerson(name string) (*PersonProfile, error) GetTrait(name, trait string) (string, bool) GetRelations(name string) ([]SocialRelation, error) GetNetwork(name string, depth int) ([]*PersonProfile, error) ListPersons() ([]string, error) } ``` 写操作只在内核内部接口里。这样外部插件**在类型层面就调不到**, 不是靠运行时检查拦截。 同理,`EventSubscriber` 公开版**刻意只有 `Subscribe`,没有 `Publish`**: ```go // 插件可以订阅,但由内核决定投递哪些事件 type EventSubscriber interface { Subscribe(eventType EventType, handler EventHandler) func() } ``` ## 进程边界带来的约束 事件订阅是理解这层边界的典型例子。公开包里有一个 `Events() EventSubscriber`, 但**外部插件拿到的恒为 nil** —— 桥接运行时不注入它(`SetEventSubscriber` 在全仓没有调用点)。外部插件的事件订阅由生成的运行时通过 `events.subscribe` RPC 完成,Lua 插件走内部 SDK 的 `Subscribe`。 这不是缺陷,而是进程边界的结果:跨进程无法共享内核的事件发布通道。 详见[能力边界](capability-boundary.md)。 ## 共享内存中的数据面 工具调用帧、Cleaner、输入输出通道、媒体块、文档与知识正文**都走共享内存**, RPC 只传偏移描述符。因此: - 大对象不经 JSON 序列化,避免了大 payload 的性能与内存放大; - StageContext 在同一份状态上读改写,消除了副本模型的 lost update (实测由 35.8~36.8% 降到 0)。 `SharedRef`(共享内存描述符)是**内部实现细节**,插件开发者看不到它 —— 公开 SDK 只暴露普通字符串与 map。 ## 插件作者的实践建议 - **不要在 `Start` 里长时间阻塞** —— 内核在等待它返回。 - **工具处理器要可并发**:模型可能并发发起多个调用;共享状态用锁保护 (`example/memo` 用 `sync.RWMutex`)。 - **写文件用原子替换**(临时文件 + rename),避免进程被强杀时截断数据。 - **声明 `NoMemory`**:定时提醒、连接状态这类不是对话内容的东西, 别让它们污染记忆(`InjectOptions{NoMemory: true}`)。 - **在 `-race` 下测**:插件重载瞬间的并发访问是历史高发缺陷。 ## 许可与分发 SDK 是 **MIT**,插件可以**闭源分发**,可商用、可私有,无需回馈。 这是刻意的:SDK 随插件静态链接(源码进入插件二进制),用传染性许可会 强迫插件开源。内核本身是 AGPL-3.0-only,但那是内核的许可,与外部插件无关。 ## # 流式多 `tool_call`:适配器必须透传 `index` > 面向在 Lua 里写适配器(`transform_stream_chunk`)的插件作者。 > > 状态:已随 2026-09 的并行内核落地;生产 `openai.lua` 等适配器已修复。 ## 1. 问题 OpenAI 兼容的流式响应里,同一轮的多个 `tool_call` 以**分片**形式到达, 靠 `index` 字段区分归属: ``` data: {"choices":[{"delta":{"tool_calls":[ {"index":0,"id":"call_a","function":{"name":"alpha","arguments":""}}]}}]} data: {"choices":[{"delta":{"tool_calls":[ {"index":1,"id":"call_b","function":{"name":"beta","arguments":""}}]}}]} data: {"choices":[{"delta":{"tool_calls":[ {"index":0,"function":{"arguments":"{\"x\":1}"}}]}}]} data: {"choices":[{"delta":{"tool_calls":[ {"index":1,"function":{"arguments":"{\"y\":2}"}}]}}]} ``` **每个 SSE chunk 通常只含一个 `tool_call` 元素。** 内核按 `index` 分桶累积 `id` / `name` / `arguments`。 ## 2. 适配器必须做的事 `transform_stream_chunk` 的输出 JSON 里,每个 tool call 分片都要带 **`stream_index`**(值取上游的 `index`): ```lua table.insert(tcs, { id = tc.id or "", type = tc.type or "function", name = name, raw_arguments = raw_args, -- ★ 必须透传上游 index(键名是 stream_index,不是 index)。 -- 内核按 stream_index 分桶累积同一轮多个 tool_call 的分片。 stream_index = tc.index or 0 }) ``` ### ★ 键名是 `stream_index`,不是 `index` 内核的 `ToolCall.StreamIndex` 标签是 `json:"stream_index"`: ```go StreamIndex int `json:"stream_index,omitempty"` ``` 写成 `index` 会被 Go 的解码器**静默丢弃**(无匹配字段), `StreamIndex` 恒为 0 ⇒ 全部落进 `accs[0]`。 ## 3. 不透传的实际后果 不是"少个字段",而是**多工具并行调用整体失效**: | 现象 | 原因 | | --- | --- | | `name` 相互覆盖 | 全进 `accs[0]`,后写的赢 | | `arguments` 碎片混拼 | 两个工具的 JSON 片段交错拼接 | | 报"参数不是合法 JSON" | 上面拼接的产物解析失败 | | 工具被当成**空参数**调用 | 同上 | 2026-09-27 的对照压测里,旧适配器耗时**更短**但**实际处理 0 个工具** —— 每个工具都因参数非法失败。⇒ 压测**必须同时统计实际执行数**,不能只看耗时。 ## 4. 还有两个容易踩的点 **① 不能按 `name` 过滤分片** ```lua -- ✗ 错:后续块的 name 为空但携带 arguments if tc.function and tc.function.name then ... end -- ✓ 对:无 name 但有 arguments 的分片也要收,累积时再校验 name ``` **② 扁平结构 + `stream_index`** 部分协议族(`server` / `kimicode` / `anthropic` / `ollama`)的流式 tool call 是**扁平**结构(`name`/`arguments` 直接在 `tc` 上,不在 `tc.function` 里), 同样要带 `stream_index`。 ## 5. 自检 ```bash # 1) 适配器是否透传 grep -n "stream_index" /home/newqqagent/adapters/<你的>.lua # 2) 实测:发一个同轮多工具的请求,看是否两个都真被执行 # 内核日志里 executing tool 应出现两次(可能并发) journalctl -u homeagent.service --since "-2 min" | grep "executing tool" # 3) 有没有参数解析失败 journalctl -u homeagent.service --since "-2 min" | grep -E "参数|合法 JSON" ``` > `gemini.lua` 目前**没有**流式 tool call 实现,因此不涉及本条。 > Gemini 协议是 `functionCall` 而非 `tool_calls`,不能照搬 OpenAI 的做法。 ## 相关 - `docs/guide/parallel-tool-declaration.md` —— 并发声明 `ParallelSafe`/`Serial` - 核心仓 `docs/zh/toolcall-parallel-execution-plan.md` —— 压测数据与协议族清单 ## # 版本与兼容 ## SDK 版本语义 **SDK 版本跟随内核的中版本,patch 位恒为 `.0`。** 整条内核 `1.1.x` 线(1.1.0、1.1.1、1.1.7…)共用 **SDK 1.1.0**; 只有内核进入 `1.2.0` 这种中版本跃迁时,SDK 才升到 1.2.0。 这样插件作者只需关心「我在为哪个中版本写插件」,不必跟着内核的每个 bugfix 换依赖。 当前内核声明的兼容上限是 **SDK 1.3.0**。 ## 版本历史 | SDK | 内核 | 变化 | 需要重编? | |---|---|---|---| | **1.3.0** | 1.4.0+ | 驻留子 agent、`RecallPolicy` 等 | 想用新 API 才需要 | | **1.2.0** | 1.2.0 / 1.3.x | `InjectOptions{NoMemory, ContextPolicy}`、六个 `*Opts` 变体、`ChannelDef.ContextPolicy` | 不需要 | | **1.1.0** | 1.1.x | 多模态贯通:`Triple.SentenceText`、`Doc.Attachments`、`MediaAttachment`、`InsertWithMedia`、媒体注入方法 | 不需要 | | **1.0.0** | 1.0.0+ | **运行模型变更**:C ABI 动态库 → 子进程 + 共享内存 | **需要** | ### 1.0.0 是唯一一次破坏性变更 - `.so` / `.dylib` / `.dll` **不再被加载**,遇到旧产物会跳过并报可操作错误(不崩溃)。 - **业务代码不用改一行** —— 公开 SDK 接口零改动,用新版 `hmapdev` 重编即可。 - 产物从 `plugin.so` 变为 `plugin.bin`;不再需要 cgo。 ### 1.1.0 / 1.2.0 是纯追加 两次都是**新增方法由插件调用、内核实现**,不调就不受影响。 零值 `InjectOptions` 与旧的三参数方法完全等价,因此存量插件**不需要改、也不需要重编**; 想用新字段的重编即可。 !!! tip "什么时候必须重编" 只有两种情况:① 内核跨了中版本(如 1.1 → 1.2)且你用了新 API; ② 内核的 RPC 协议版本变了(`.hmap` 里的 `protocol` 字段与内核不匹配)。 后者的错配**不会静默失效** —— 握手时会显式拦下。 ## RPC 协议版本 插件包里带 `protocol` 字段,必须等于内核的 `ProtocolVersion`(当前 **2**)。 协议 v2 引入了调用帧(tool / cleaner / output)与 `blocks_ref` 媒体块。 v1 插件遇上 v2 内核会拿到空参数,反过来 v2 插件发 `blocks_ref` 会被 v1 内核静默忽略 —— **两边错配都不报错、只是静默失效**,所以协议版本在握手上显式校验。 ## 怎么确认自己在用什么 装的 SDK 版本: ```bash hmapdev sdk current hmapdev sdk list ``` 插件声明的目标版本在 `plg.json` 的 `sdk` 字段。若该版本不在本地存储里, `hmapdev` 会**明确报错**,不静默降级 —— 静默降级会产出与内核协议不匹配的包, 那种失败要到运行时才暴露。 ## 文档站对应的版本 本页与 [API 参考](api/index.md) 由 `tools/apidoc` 从源码生成, 内容随源码一起演进。发现文档与代码不一致时,**改的是源码注释**, `go run ./tools/apidoc` 重新生成即可(见下方「维护」)。 ## 维护(给 SDK 维护者) ```bash cd homeagent-sdk go run ./tools/apidoc -pkgdir ./sdk -out /tmp/api.json go run ./tools/apidoc/gensite -api /tmp/api.json -out ./docs -examples ./example mkdocs serve # 本地预览 mkdocs build # 产出 site_build/ ``` API 面的**能力分层**(哪些 API 仅内置可用)记在 `tools/apidoc/tiers.json`, 每条裁定都附源码依据 —— 改这里而不是改生成物。