diff --git a/assets/docs/en/PLUGIN_DEV.md b/assets/docs/en/PLUGIN_DEV.md index 4104851..68895c7 100644 --- a/assets/docs/en/PLUGIN_DEV.md +++ b/assets/docs/en/PLUGIN_DEV.md @@ -292,7 +292,7 @@ Tool output → valuable for LLM attention? └── No → Normal memory, no extra handling ``` -> **Note**: `Cleaner` is a Go `func` type (`json:"-"`), cannot cross C ABI boundaries. Not available for Lua plugins or remote plugins. +> **Note**: `Cleaner` is a Go `func` type (`json:"-"`), cannot cross C ABI boundaries, so it is unavailable for C/C++/Rust remote plugins. **Lua plugins are not affected**: pass a Lua function in the def table (`cleaner = function(text) return text end`) — the Go bridge calls it back per invocation during memory computation. #### Stage Hooks — Intervene in message processing flow @@ -538,22 +538,49 @@ When running inside the kernel, `sdk.*` global variables are injected by the Go ### Lua SDK API +The `sdk.*` API of Lua plugins is fully aligned with external plugins (C ABI / toolchain-built `.so`/`.dll`): registration functions raise a Lua error on failure; data functions uniformly return `(result, err)` with `err == nil` on success. Subsystems not wired by the core (e.g. SocialAPI) return empty values instead of errors. + +**Registration** + | Function | Description | |----------|-------------| | `sdk.log(level, msg)` | Log output | -| `sdk.register_tool(name, def, handler)` | Register tool | -| `sdk.register_stage(stage, handler)` | Register stage hook | +| `sdk.register_tool(name, def, handler)` | Register tool; `def` supports `description`, `parameters`, `no_memory`, `cleaner` | +| `sdk.register_stage(stage, handler, scope)` | Register stage hook; `scope` is `nil`/`"global"` (default) or `"own_tools"` (fires only for `before_toolcall`/`after_toolcall` when the tool belongs to this plugin) | | `sdk.register_api(name)` | Register API | -| `sdk.get_setting(key)` | Read config | -| `sdk.set_setting(key, value)` | Write config | +| `sdk.register_output_channel(name, caps, desc, def, handler)` | Register output channel; `def` supports `no_memory`, `cleaner` | +| `sdk.register_input_channel(name, def)` | Register input channel; `def` as above | +| `sdk.set_auto_restart(enabled)` | Auto-restart the plugin after a crash | + +**Stage hook context** + +Stage handlers receive the full context (same as external plugins): `raw_message`, `user_id`, `group_id`, `phase`, `llm_text`, `final_text`, `no_memory`, `response` (when responded), `tool_calls`, `tool_results`. + +**IO and config** + +| Function | Description | +|----------|-------------| +| `sdk.get_setting(key)` / `sdk.set_setting(key, value)` | Own plugin config read/write | +| `sdk.settings.get_core/set_core/list_core(key)` | Core config read/write | +| `sdk.settings.get_plugin/set_plugin/list_plugin(plugin, key)` | Other plugin config read/write | +| `sdk.settings.list/defs/dump/plugins(prefix)` | Config queries | +| `sdk.settings.register_def(def)` | Register config definition (WebUI display) | | `sdk.inject_text(source, channel, text)` | Deliver text message | | `sdk.inject_interrupt(source, channel, text)` | Interrupt delivery | -| `sdk.json.encode(val)` | JSON encode | -| `sdk.json.decode(str)` | JSON decode | -| `sdk.http.get(url)` | HTTP GET request (`-- !impl`) | -| `sdk.http.post(url, body, content_type)` | HTTP POST request (`-- !impl`) | +| `sdk.inject_text_no_memory(source, channel, text)` | Deliver without memory computation | -> **Note**: Lua plugin's `sdk.register_stage` callback currently only receives `raw_message`, `user_id`, `phase` fields. The functionality is limited. For complex stage handling logic, use Go plugins. +**Data APIs (aligned with C ABI, all return `(result, err)`)** + +| Sub-table | Functions | +|-----------|-----------| +| `sdk.memory.*` | `recall(query, depth)`, `commit({triples})`, `introspect()`, `merge(source, target)`, `purge(criteria, hard)` | +| `sdk.doc.*` | `query(text, top_k)`, `insert({id,title,content})`, `remove(id)`, `stats()` | +| `sdk.knowledge.*` | `search(query, limit)`, `add(tag, content)`, `list()` | +| `sdk.text_memory.*` | `append({role,content,timestamp,channel})` | +| `sdk.llm.*` | `list_sources()`, `set_source(name)`, `current_source()` | +| `sdk.social.*` (read-only) | `get_person(name)`, `get_network(name, depth)`, `get_trait(name, trait)`, `get_relations(name)`, `list_persons()` | +| `sdk.json.*` | `encode(val)`, `decode(str)` | +| `sdk.http.*` | `get(url)`, `post(url, body, content_type)` | --- @@ -679,15 +706,21 @@ Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabl | Example | Type | Features | |---------|------|----------| +| [weather](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/weather) | Go | Weather queries (wttr.in); demonstrates NoMemory/Cleaner/stage hooks/channels/text memory | +| [luademo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/luademo) | Lua | Full-featured Lua example covering the whole v0.8.0 Lua SDK surface | +| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot integration, 17 tools, full input/output channel wiring | | [memo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/memo) | Go | Memo management, PreAction injection + timed interrupt dual reminder | | [files](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/files) | Go | File system operations, 4 write modes, sandbox isolation | | [browser](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/browser) | Go | Web search + HTTP fetch (SSRF) + Chromium render (merged from web/webfetch) | | [bili](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/bili) | Go | Bilibili video download (yt-dlp) | -| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot integration, 17 tools | | [editdoc](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/editdoc) | Go | Office document editing and format conversion | | [a2a](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/a2a) | Go | Agent-to-Agent protocol | | [ocr](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ocr) | Go | Offline text recognition (Tesseract) | | [sanitizer](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/sanitizer) | Go | Output sanitizer filter | +| [calendar](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/calendar) | Go | Calendar management | +| [rss](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/rss) | Go | RSS subscriptions | +| [ai_image](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ai_image) | Go | AI image generation | +| [music](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/music) | Go | Music playback | ### Built-in Plugins diff --git a/assets/docs/zh/PLUGIN_DEV.md b/assets/docs/zh/PLUGIN_DEV.md index e1f5b27..2508273 100644 --- a/assets/docs/zh/PLUGIN_DEV.md +++ b/assets/docs/zh/PLUGIN_DEV.md @@ -290,7 +290,7 @@ s.RegisterTool("weather_query", sdk.ToolDef{ └── 否 → 正常记忆,无需额外处理 ``` -> **注意**:`Cleaner` 是 Go `func` 类型(`json:"-"`),不能跨 C ABI 边界序列化。Lua 插件和远程插件无法使用。 +> **注意**:`Cleaner` 是 Go `func` 类型(`json:"-"`),不能跨 C ABI 边界序列化,因此 C/C++/Rust 等远程插件无法使用。**Lua 插件不受此限**:def 表中直接传 Lua 函数即可(`cleaner = function(text) return text end`),Go 桥接层会在计算层调用时逐次回调 Lua。 #### 阶段钩子 — 干预消息处理流 @@ -536,22 +536,49 @@ lua main.lua ### Lua SDK API +Lua 插件的 `sdk.*` API 与外部插件(C ABI / 工具链编译的 `.so`/`.dll`)能力完全对齐:注册类函数调用即时报错(抛 Lua error),数据类函数统一返回 `(result, err)`,`err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI)返回空值而非报错。 + +**注册类** + | 函数 | 说明 | |------|------| | `sdk.log(level, msg)` | 日志输出 | -| `sdk.register_tool(name, def, handler)` | 注册工具 | -| `sdk.register_stage(stage, handler)` | 注册阶段钩子 | +| `sdk.register_tool(name, def, handler)` | 注册工具;`def` 支持 `description`、`parameters`、`no_memory`、`cleaner` | +| `sdk.register_stage(stage, handler, scope)` | 注册阶段钩子;`scope` 为 `nil`/`"global"`(默认)或 `"own_tools"`(仅 `before_toolcall`/`after_toolcall` 且工具属于本插件时触发) | | `sdk.register_api(name)` | 注册 API | -| `sdk.get_setting(key)` | 读取配置 | -| `sdk.set_setting(key, value)` | 写入配置 | +| `sdk.register_output_channel(name, caps, desc, def, handler)` | 注册输出通道;`def` 支持 `no_memory`、`cleaner` | +| `sdk.register_input_channel(name, def)` | 注册输入通道;`def` 同上 | +| `sdk.set_auto_restart(enabled)` | 崩溃时内核自动拉起插件 | + +**阶段钩子上下文** + +`register_stage` 的 handler 收到完整上下文(与外部插件一致):`raw_message`、`user_id`、`group_id`、`phase`、`llm_text`、`final_text`、`no_memory`、`response`(已响应时)、`tool_calls`、`tool_results`。 + +**IO 与配置** + +| 函数 | 说明 | +|------|------| +| `sdk.get_setting(key)` / `sdk.set_setting(key, value)` | 本插件配置读写 | +| `sdk.settings.get_core/set_core/list_core(key)` | 核心配置读写 | +| `sdk.settings.get_plugin/set_plugin/list_plugin(plugin, key)` | 其他插件配置读写 | +| `sdk.settings.list/defs/dump/plugins(prefix)` | 配置查询 | +| `sdk.settings.register_def(def)` | 注册配置项定义(WebUI 展示) | | `sdk.inject_text(source, channel, text)` | 投递文本消息 | | `sdk.inject_interrupt(source, channel, text)` | 中断投递 | -| `sdk.json.encode(val)` | JSON 编码 | -| `sdk.json.decode(str)` | JSON 解码 | -| `sdk.http.get(url)` | HTTP GET 请求(`-- !impl`) | -| `sdk.http.post(url, body, content_type)` | HTTP POST 请求(`-- !impl`) | +| `sdk.inject_text_no_memory(source, channel, text)` | 免记忆投递 | -> **注意**:Lua 插件的 `sdk.register_stage` 阶段回调目前仅传递 `raw_message`、`user_id`、`phase` 三个字段,功能受限。复杂的阶段处理逻辑建议使用 Go 插件。 +**数据类(与 C ABI 对齐,均返回 `(result, err)`)** + +| 子表 | 函数 | +|------|------| +| `sdk.memory.*` | `recall(query, depth)`、`commit({triples})`、`introspect()`、`merge(source, target)`、`purge(criteria, hard)` | +| `sdk.doc.*` | `query(text, top_k)`、`insert({id,title,content})`、`remove(id)`、`stats()` | +| `sdk.knowledge.*` | `search(query, limit)`、`add(tag, content)`、`list()` | +| `sdk.text_memory.*` | `append({role,content,timestamp,channel})` | +| `sdk.llm.*` | `list_sources()`、`set_source(name)`、`current_source()` | +| `sdk.social.*`(只读) | `get_person(name)`、`get_network(name, depth)`、`get_trait(name, trait)`、`get_relations(name)`、`list_persons()` | +| `sdk.json.*` | `encode(val)`、`decode(str)` | +| `sdk.http.*` | `get(url)`、`post(url, body, content_type)` | --- @@ -677,15 +704,21 @@ pmgr.ReloadPlugins() // 重载所有插件 | 示例 | 类型 | 特点 | |------|------|------| +| [weather](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/weather) | Go | 天气查询(wttr.in),演示 NoMemory/Cleaner/阶段钩子/通道/文本记忆 | +| [luademo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/luademo) | Lua | Lua 全功能示例,覆盖 v0.8.0 Lua SDK 全部 API 面 | +| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot 对接,17 个工具,输入/输出通道完整对接 | | [memo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/memo) | Go | 备忘管理,PreAction 注入 + 定时打断双提醒 | | [files](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/files) | Go | 文件系统操作,4 种写入模式,沙箱隔离 | | [browser](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/browser) | Go | 网络搜索、网页抓取(SSRF)、浏览器渲染(合并自 web/webfetch) | | [bili](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/bili) | Go | B 站视频下载(yt-dlp) | -| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot 对接,17 个工具 | | [editdoc](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/editdoc) | Go | Office 文档编辑与格式转换 | | [a2a](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/a2a) | Go | Agent-to-Agent 协议 | | [ocr](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ocr) | Go | 离线文字识别(Tesseract) | | [sanitizer](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/sanitizer) | Go | 输出清洗过滤器 | +| [calendar](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/calendar) | Go | 日历管理 | +| [rss](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/rss) | Go | RSS 订阅 | +| [ai_image](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ai_image) | Go | AI 图片生成 | +| [music](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/music) | Go | 音乐播放 | ### 内置插件 diff --git a/internal/lua/sdk/sdk.lua b/internal/lua/sdk/sdk.lua index cb94e1a..71f0b6e 100644 --- a/internal/lua/sdk/sdk.lua +++ b/internal/lua/sdk/sdk.lua @@ -13,7 +13,7 @@ function sdk.log(level, msg) end -- !impl --- def: { description="...", parameters={...} } +-- def: { description="...", parameters={...}, no_memory=true/false, cleaner=function(text)->text } -- handler: function(args) -> result function sdk.register_tool(name, def, handler) print("[lua-plugin] register_tool: " .. tostring(name)) @@ -21,8 +21,9 @@ end -- !impl -- stage: "on_input" | "pre_action" | "post_action" | ... -function sdk.register_stage(stage, handler) - print("[lua-plugin] register_stage: " .. tostring(stage)) +-- scope: nil/"global" (默认) | "own_tools"(仅 before_toolcall/after_toolcall 且工具属于本插件时触发) +function sdk.register_stage(stage, handler, scope) + print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope)) end -- !impl @@ -30,6 +31,19 @@ function sdk.register_api(name) print("[lua-plugin] register_api: " .. tostring(name)) end +-- !impl +-- def: { no_memory=true/false, cleaner=function(text)->text } +-- handler: function(args) -> result +function sdk.register_output_channel(name, caps, desc, def, handler) + print("[lua-plugin] register_output_channel: " .. tostring(name)) +end + +-- !impl +-- def: { no_memory=true/false, cleaner=function(text)->text } +function sdk.register_input_channel(name, def) + print("[lua-plugin] register_input_channel: " .. tostring(name)) +end + -- !impl function sdk.get_setting(key) return nil @@ -55,6 +69,111 @@ function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end +-- !impl +-- enabled: true/false,崩溃时内核自动拉起 +function sdk.set_auto_restart(enabled) + print("[lua-plugin] set_auto_restart: " .. tostring(enabled)) +end + +-- ============ graph memory ============ +-- !impl +sdk.memory = {} +-- !impl +-- query: string, depth: number -> {entities={...}, relations={...}} +function sdk.memory.recall(query, depth) return {entities={}, relations={}} end +-- !impl +-- triples: { {subject=, relation=, object=, [confidence=], [sentence_text=]} } -> err +function sdk.memory.commit(triples) return nil end +-- !impl +function sdk.memory.introspect() return {} end +-- !impl +function sdk.memory.merge(source, target) return 0 end +-- !impl +-- criteria: {key=value}, hard: boolean +function sdk.memory.purge(criteria, hard) return 0 end + +-- ============ document memory ============ +-- !impl +sdk.doc = {} +-- !impl +function sdk.doc.query(text, top_k) return {} end +-- !impl +-- doc: { id=, title=, content= } +function sdk.doc.insert(doc) return nil end +-- !impl +function sdk.doc.remove(id) return nil end +-- !impl +function sdk.doc.stats() return {} end + +-- ============ knowledge ============ +-- !impl +sdk.knowledge = {} +-- !impl +function sdk.knowledge.search(query, limit) return {} end +-- !impl +function sdk.knowledge.add(tag, content) return nil end +-- !impl +function sdk.knowledge.list() return {} end + +-- ============ text memory ============ +-- !impl +sdk.text_memory = {} +-- !impl +-- evt: { timestamp=, role=, content=, channel= } +function sdk.text_memory.append(evt) return nil end + +-- ============ llm ============ +-- !impl +sdk.llm = {} +-- !impl +function sdk.llm.list_sources() return {} end +-- !impl +function sdk.llm.set_source(name) return nil end +-- !impl +function sdk.llm.current_source() return nil end + +-- ============ social (只读) ============ +-- !impl +sdk.social = {} +-- !impl +function sdk.social.get_person(name) return {} end +-- !impl +function sdk.social.get_network(name, depth) return {} end +-- !impl +function sdk.social.get_trait(name, trait) return {value=nil, found=false} end +-- !impl +function sdk.social.get_relations(name) return {} end +-- !impl +function sdk.social.list_persons() return {} end + +-- ============ settings (作用域变体) ============ +-- !impl +sdk.settings = {} +-- !impl +function sdk.settings.get_core(key) return nil end +-- !impl +function sdk.settings.set_core(key, value) return nil end +-- !impl +function sdk.settings.list_core(prefix) return {} end +-- !impl +function sdk.settings.get_plugin(plugin, key) return nil end +-- !impl +function sdk.settings.set_plugin(plugin, key, value) return nil end +-- !impl +function sdk.settings.list_plugin(plugin, prefix) return {} end +-- !impl +function sdk.settings.list(prefix) return {} end +-- !impl +-- def: { key=, type=, display_name=, description=, category=, options=, default=, +-- min=, max=, step=, required=, secret= } +function sdk.settings.register_def(def) return nil end +-- !impl +function sdk.settings.defs(prefix) return {} end +-- !impl +function sdk.settings.dump() return {} end +-- !impl +function sdk.settings.plugins() return {} end + -- json utils (pure Lua) sdk.json = {} diff --git a/internal/meta/meta.go b/internal/meta/meta.go index 7d3753b..f9be2fc 100644 --- a/internal/meta/meta.go +++ b/internal/meta/meta.go @@ -83,4 +83,5 @@ const ( CoreSettingsDefs = 43 CoreSettingsDump = 44 CoreSettingsPlugins = 45 + CoreRegisterInputCh = 46 ) diff --git a/internal/plugin/cabi/loader.go b/internal/plugin/cabi/loader.go index 7a44e07..0504a17 100644 --- a/internal/plugin/cabi/loader.go +++ b/internal/plugin/cabi/loader.go @@ -650,6 +650,17 @@ func go_core_dispatch(methodID C.int, ctx unsafe.Pointer, s1, s2, s3 *C.char, i1 setResult(result, string(b)) } return 0 + + case 46: // CORE_REGISTER_INPUT_CH + chDef := sdk.ChannelDef{} + if a2 != "" { + var def sdk.ChannelDef + if err := json.Unmarshal([]byte(a2), &def); err == nil { + chDef = def + } + } + s.RegisterInputChannel(a1, chDef) + return 0 } return 0 } diff --git a/internal/plugin/cabi/types.go b/internal/plugin/cabi/types.go index 8b732d5..a459958 100644 --- a/internal/plugin/cabi/types.go +++ b/internal/plugin/cabi/types.go @@ -55,4 +55,5 @@ const ( CoreSettingsDefs = meta.CoreSettingsDefs CoreSettingsDump = meta.CoreSettingsDump CoreSettingsPlugins = meta.CoreSettingsPlugins + CoreRegisterInputCh = meta.CoreRegisterInputCh ) diff --git a/internal/plugin/lua_plugin.go b/internal/plugin/lua_plugin.go index 8d11031..9718392 100644 --- a/internal/plugin/lua_plugin.go +++ b/internal/plugin/lua_plugin.go @@ -1,9 +1,11 @@ package plugin import ( + "encoding/json" "fmt" "io" "net/http" + "reflect" "strings" "sync" @@ -17,14 +19,28 @@ type toolReg struct { handler *lua.LFunction } +type outputChReg struct { + caps int + desc string + def sdk.ChannelDef + handler *lua.LFunction +} + +type stageReg struct { + handler *lua.LFunction + scope sdk.StageScope +} + // luaPlugin wraps a Lua script as an sdk.Plugin. type luaPlugin struct { - name string - L *lua.LState - tbl *lua.LTable - tools map[string]*toolReg - stages map[sdk.Stage]*lua.LFunction - mu sync.Mutex + name string + L *lua.LState + tbl *lua.LTable + tools map[string]*toolReg + stages map[sdk.Stage]*stageReg + outputChs map[string]*outputChReg + inputDefs map[string]sdk.ChannelDef + mu sync.Mutex } func newLuaPlugin(luaPath, name string) (*luaPlugin, error) { @@ -47,10 +63,12 @@ func newLuaPlugin(luaPath, name string) (*luaPlugin, error) { L.SetTop(0) plg := &luaPlugin{ - name: name, - L: L, - tools: make(map[string]*toolReg), - stages: make(map[sdk.Stage]*lua.LFunction), + name: name, + L: L, + tools: make(map[string]*toolReg), + stages: make(map[sdk.Stage]*stageReg), + outputChs: make(map[string]*outputChReg), + inputDefs: make(map[string]sdk.ChannelDef), } // 2) 替换 !impl 函数为 Go stub(暂存 handler,等 Start 时注册到真实 SDK) @@ -88,16 +106,7 @@ func replaceSDKStubs(L *lua.LState, t *lua.LTable, plg *luaPlugin) { defTbl := L.CheckTable(2) handler := L.CheckFunction(3) - goDef := sdk.ToolDef{Name: toolName, Plugin: plg.name} - goDef.Description = defTbl.RawGetString("description").String() - if params := defTbl.RawGetString("parameters"); params != nil { - if pt, ok := params.(*lua.LTable); ok { - goDef.Parameters = make(map[string]interface{}) - pt.ForEach(func(k, v lua.LValue) { - goDef.Parameters[k.String()] = luaValueToGo(v) - }) - } - } + goDef := parseToolDef(L, defTbl, plg, toolName) plg.mu.Lock() plg.tools[toolName] = &toolReg{def: goDef, handler: handler} @@ -108,8 +117,9 @@ func replaceSDKStubs(L *lua.LState, t *lua.LTable, plg *luaPlugin) { t.RawSetString("register_stage", L.NewFunction(func(L *lua.LState) int { stage := sdk.Stage(L.CheckString(1)) handler := L.CheckFunction(2) + scope := parseStageScope(L) plg.mu.Lock() - plg.stages[stage] = handler + plg.stages[stage] = &stageReg{handler: handler, scope: scope} plg.mu.Unlock() return 0 })) @@ -118,6 +128,33 @@ func replaceSDKStubs(L *lua.LState, t *lua.LTable, plg *luaPlugin) { return 0 })) + t.RawSetString("register_output_channel", L.NewFunction(func(L *lua.LState) int { + name := L.CheckString(1) + caps := L.CheckInt(2) + desc := L.CheckString(3) + defTbl := L.CheckTable(4) + handler := L.CheckFunction(5) + + chDef := parseChannelDef(L, defTbl, plg) + + plg.mu.Lock() + plg.outputChs[name] = &outputChReg{caps: caps, desc: desc, def: chDef, handler: handler} + plg.mu.Unlock() + return 0 + })) + + t.RawSetString("register_input_channel", L.NewFunction(func(L *lua.LState) int { + name := L.CheckString(1) + defTbl := L.CheckTable(2) + + chDef := parseChannelDef(L, defTbl, plg) + + plg.mu.Lock() + plg.inputDefs[name] = chDef + plg.mu.Unlock() + return 0 + })) + t.RawSetString("get_setting", L.NewFunction(func(L *lua.LState) int { L.Push(lua.LNil) return 1 @@ -184,16 +221,7 @@ func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginS defTbl := L.CheckTable(2) handler := L.CheckFunction(3) - goDef := sdk.ToolDef{Name: toolName, Plugin: plg.name} - goDef.Description = defTbl.RawGetString("description").String() - if params := defTbl.RawGetString("parameters"); params != nil { - if pt, ok := params.(*lua.LTable); ok { - goDef.Parameters = make(map[string]interface{}) - pt.ForEach(func(k, v lua.LValue) { - goDef.Parameters[k.String()] = luaValueToGo(v) - }) - } - } + goDef := parseToolDef(L, defTbl, plg, toolName) h := makeToolHandler(plg, toolName, handler) if err := s.RegisterTool(toolName, goDef, h); err != nil { @@ -205,9 +233,10 @@ func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginS t.RawSetString("register_stage", L.NewFunction(func(L *lua.LState) int { stage := sdk.Stage(L.CheckString(1)) handler := L.CheckFunction(2) + scope := parseStageScope(L) h := makeStageHandler(plg, stage, handler) - s.RegisterStage(stage, h) + s.RegisterStage(stage, h, scope) return 0 })) @@ -217,6 +246,34 @@ func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginS return 0 })) + t.RawSetString("register_output_channel", L.NewFunction(func(L *lua.LState) int { + name := L.CheckString(1) + caps := L.CheckInt(2) + desc := L.CheckString(3) + defTbl := L.CheckTable(4) + handler := L.CheckFunction(5) + + chDef := parseChannelDef(L, defTbl, plg) + + h := makeOutputHandler(plg, handler) + if err := s.RegisterOutputChannel(name, caps, desc, chDef, h); err != nil { + L.RaiseError("register_output_channel: %v", err) + } + return 0 + })) + + t.RawSetString("register_input_channel", L.NewFunction(func(L *lua.LState) int { + name := L.CheckString(1) + defTbl := L.CheckTable(2) + + chDef := parseChannelDef(L, defTbl, plg) + + if err := s.RegisterInputChannel(name, chDef); err != nil { + L.RaiseError("register_input_channel: %v", err) + } + return 0 + })) + t.RawSetString("get_setting", L.NewFunction(func(L *lua.LState) int { key := L.CheckString(1) val, _ := s.Settings().Get(key) @@ -242,6 +299,407 @@ func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginS s.InjectTextNoMemory(L.CheckString(1), L.CheckString(2), L.CheckString(3)) return 0 })) + + // ---- 数据类 API(与 C ABI 外部插件面完全对齐)---- + // 约定:结果型返回 (result, err),void 型返回 (nil, err),成功时 err 为 nil。 + + subTable := func(name string) *lua.LTable { + if v := t.RawGetString(name); v != nil { + if st, ok := v.(*lua.LTable); ok { + return st + } + } + st := L.NewTable() + t.RawSetString(name, st) + return st + } + pushVal := func(val interface{}) int { + L.Push(jsonToLuaValue(L, val)) + L.Push(lua.LNil) + return 2 + } + // pushList 归一化 nil/空 切片与 map 为 Lua 空表。 + pushList := func(val interface{}) int { + if val == nil { + return pushVal([]interface{}{}) + } + v := reflect.ValueOf(val) + switch v.Kind() { + case reflect.Slice, reflect.Array, reflect.Map: + if v.Len() == 0 { + return pushVal([]interface{}{}) + } + } + return pushVal(val) + } + pushErr := func(err error) int { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + pushNil := func() int { + L.Push(lua.LNil) + L.Push(lua.LNil) + return 2 + } + + // sdk.set_auto_restart(enabled) + t.RawSetString("set_auto_restart", L.NewFunction(func(L *lua.LState) int { + s.SetAutoRestart(L.CheckBool(1)) + return 0 + })) + + // ---- sdk.memory.* (graph memory, 对齐 CORE_MEMORY_*) ---- + memTbl := subTable("memory") + memTbl.RawSetString("recall", L.NewFunction(func(L *lua.LState) int { + if m := s.Memory(); m != nil { + var query []string + switch v := L.Get(1).(type) { + case *lua.LTable: + v.ForEach(func(_, e lua.LValue) { query = append(query, e.String()) }) + default: + query = []string{L.CheckString(1)} + } + entities, relations, err := m.Recall(query, L.OptInt(2, 1)) + if err != nil { + return pushErr(err) + } + return pushVal(map[string]interface{}{"entities": entities, "relations": relations}) + } + return pushVal(map[string]interface{}{"entities": []interface{}{}, "relations": []interface{}{}}) + })) + memTbl.RawSetString("commit", L.NewFunction(func(L *lua.LState) int { + var triples []sdk.Triple + if tbl := L.OptTable(1, nil); tbl != nil { + tbl.ForEach(func(_, v lua.LValue) { + if t2, ok := v.(*lua.LTable); ok { + triples = append(triples, sdk.Triple{ + Subject: t2.RawGetString("subject").String(), + Relation: t2.RawGetString("relation").String(), + Object: t2.RawGetString("object").String(), + Confidence: float64(lua.LVAsNumber(t2.RawGetString("confidence"))), + SubjectType: t2.RawGetString("subject_type").String(), + ObjectType: t2.RawGetString("object_type").String(), + }) + } + }) + } + if m := s.Memory(); m != nil { + if err := m.Commit(triples); err != nil { + return pushErr(err) + } + } + return pushNil() + })) + memTbl.RawSetString("introspect", L.NewFunction(func(L *lua.LState) int { + if m := s.Memory(); m != nil { + r, err := m.Introspect() + if err != nil { + return pushErr(err) + } + return pushVal(r) + } + return pushVal(map[string]interface{}{}) + })) + memTbl.RawSetString("merge", L.NewFunction(func(L *lua.LState) int { + if m := s.Memory(); m != nil { + n, err := m.MergeEntities(L.CheckString(1), L.CheckString(2)) + if err != nil { + return pushErr(err) + } + return pushVal(n) + } + return pushVal(0) + })) + memTbl.RawSetString("purge", L.NewFunction(func(L *lua.LState) int { + mode := "soft" + if L.OptBool(2, false) { + mode = "hard" + } + criteria := map[string]string{} + if tbl := L.OptTable(1, nil); tbl != nil { + tbl.ForEach(func(k, v lua.LValue) { + criteria[k.String()] = v.String() + }) + } + if m := s.Memory(); m != nil { + n, err := m.Purge(criteria, mode) + if err != nil { + return pushErr(err) + } + return pushVal(n) + } + return pushVal(0) + })) + + // ---- sdk.doc.* (document memory, 对齐 CORE_DOC_*) ---- + docTbl := subTable("doc") + docTbl.RawSetString("query", L.NewFunction(func(L *lua.LState) int { + if dm := s.DocMemory(); dm != nil { + return pushList(dm.Query(L.CheckString(1), L.OptInt(2, 5))) + } + return pushVal([]interface{}{}) + })) + docTbl.RawSetString("insert", L.NewFunction(func(L *lua.LState) int { + if dm := s.DocMemory(); dm != nil { + tbl := L.CheckTable(1) + if err := dm.Insert(&sdk.Doc{ + ID: tbl.RawGetString("id").String(), + Title: tbl.RawGetString("title").String(), + Content: tbl.RawGetString("content").String(), + }); err != nil { + return pushErr(err) + } + } + return pushNil() + })) + docTbl.RawSetString("remove", L.NewFunction(func(L *lua.LState) int { + if dm := s.DocMemory(); dm != nil { + dm.Remove(L.CheckString(1)) + } + return pushNil() + })) + docTbl.RawSetString("stats", L.NewFunction(func(L *lua.LState) int { + if dm := s.DocMemory(); dm != nil { + return pushVal(dm.Stats()) + } + return pushVal(map[string]interface{}{}) + })) + + // ---- sdk.knowledge.* (对齐 CORE_KNOWLEDGE_*) ---- + knTbl := subTable("knowledge") + knTbl.RawSetString("search", L.NewFunction(func(L *lua.LState) int { + if kn := s.Knowledge(); kn != nil { + results, err := kn.Search(L.CheckString(1), L.OptInt(2, 5)) + if err != nil { + return pushErr(err) + } + return pushList(results) + } + return pushVal([]interface{}{}) + })) + knTbl.RawSetString("add", L.NewFunction(func(L *lua.LState) int { + if kn := s.Knowledge(); kn != nil { + if err := kn.Add(L.CheckString(1), L.CheckString(2)); err != nil { + return pushErr(err) + } + } + return pushNil() + })) + knTbl.RawSetString("list", L.NewFunction(func(L *lua.LState) int { + if kn := s.Knowledge(); kn != nil { + list, err := kn.List() + if err != nil { + return pushErr(err) + } + return pushList(list) + } + return pushVal([]interface{}{}) + })) + + // ---- sdk.text_memory.* (对齐 CORE_TEXT_MEMORY_APPEND) ---- + tmTbl := subTable("text_memory") + tmTbl.RawSetString("append", L.NewFunction(func(L *lua.LState) int { + if tmem := s.TextMemory(); tmem != nil { + tbl := L.CheckTable(1) + if err := tmem.Append(sdk.TextEvent{ + Role: tbl.RawGetString("role").String(), + Content: tbl.RawGetString("content").String(), + Timestamp: int64(lua.LVAsNumber(tbl.RawGetString("timestamp"))), + Channel: tbl.RawGetString("channel").String(), + }); err != nil { + return pushErr(err) + } + } + return pushNil() + })) + + // ---- sdk.llm.* (对齐 CORE_LLM_*) ---- + llmTbl := subTable("llm") + llmTbl.RawSetString("list_sources", L.NewFunction(func(L *lua.LState) int { + if llm := s.LLM(); llm != nil { + return pushList(llm.ListSources()) + } + return pushVal([]interface{}{}) + })) + llmTbl.RawSetString("set_source", L.NewFunction(func(L *lua.LState) int { + if llm := s.LLM(); llm != nil { + if err := llm.SetSource(L.CheckString(1)); err != nil { + return pushErr(err) + } + } + return pushNil() + })) + llmTbl.RawSetString("current_source", L.NewFunction(func(L *lua.LState) int { + if llm := s.LLM(); llm != nil { + return pushVal(llm.CurrentSource()) + } + return pushVal(nil) + })) + + // ---- sdk.social.* (只读,对齐 CORE_SOCIAL_*,当前核心未装配 SocialAPI 时为 nil) ---- + socTbl := subTable("social") + socTbl.RawSetString("get_person", L.NewFunction(func(L *lua.LState) int { + if social := s.Social(); social != nil { + p, err := social.GetPerson(L.CheckString(1)) + if err != nil { + return pushErr(err) + } + return pushVal(p) + } + return pushVal(nil) + })) + socTbl.RawSetString("get_network", L.NewFunction(func(L *lua.LState) int { + if social := s.Social(); social != nil { + profiles, err := social.GetNetwork(L.CheckString(1), L.OptInt(2, 1)) + if err != nil { + return pushErr(err) + } + return pushList(profiles) + } + return pushVal([]interface{}{}) + })) + socTbl.RawSetString("get_trait", L.NewFunction(func(L *lua.LState) int { + if social := s.Social(); social != nil { + val, ok := social.GetTrait(L.CheckString(1), L.CheckString(2)) + return pushVal(map[string]interface{}{"value": val, "found": ok}) + } + return pushVal(map[string]interface{}{"value": nil, "found": false}) + })) + socTbl.RawSetString("get_relations", L.NewFunction(func(L *lua.LState) int { + if social := s.Social(); social != nil { + rels, err := social.GetRelations(L.CheckString(1)) + if err != nil { + return pushErr(err) + } + return pushList(rels) + } + return pushVal([]interface{}{}) + })) + socTbl.RawSetString("list_persons", L.NewFunction(func(L *lua.LState) int { + if social := s.Social(); social != nil { + persons, err := social.ListPersons() + if err != nil { + return pushErr(err) + } + return pushList(persons) + } + return pushVal([]interface{}{}) + })) + + // ---- sdk.settings.* (作用域变体,对齐 CORE_SETTINGS_*) ---- + settTbl := subTable("settings") + settTbl.RawSetString("get_core", L.NewFunction(func(L *lua.LState) int { + if st := s.Settings(); st != nil { + v, err := st.GetCore(L.CheckString(1)) + if err != nil { + return pushErr(err) + } + return pushVal(v) + } + return pushVal(nil) + })) + settTbl.RawSetString("set_core", L.NewFunction(func(L *lua.LState) int { + if st := s.Settings(); st != nil { + if err := st.SetCore(L.CheckString(1), luaValueToGo(L.CheckAny(2))); err != nil { + return pushErr(err) + } + } + return pushNil() + })) + settTbl.RawSetString("list_core", L.NewFunction(func(L *lua.LState) int { + if st := s.Settings(); st != nil { + keys, err := st.ListCore(L.OptString(1, "")) + if err != nil { + return pushErr(err) + } + return pushList(keys) + } + return pushVal([]interface{}{}) + })) + settTbl.RawSetString("get_plugin", L.NewFunction(func(L *lua.LState) int { + if st := s.Settings(); st != nil { + v, err := st.GetPlugin(L.CheckString(1), L.CheckString(2)) + if err != nil { + return pushErr(err) + } + return pushVal(v) + } + return pushVal(nil) + })) + settTbl.RawSetString("set_plugin", L.NewFunction(func(L *lua.LState) int { + if st := s.Settings(); st != nil { + if err := st.SetPlugin(L.CheckString(1), L.CheckString(2), luaValueToGo(L.CheckAny(3))); err != nil { + return pushErr(err) + } + } + return pushNil() + })) + settTbl.RawSetString("list_plugin", L.NewFunction(func(L *lua.LState) int { + if st := s.Settings(); st != nil { + keys, err := st.ListPlugin(L.CheckString(1), L.OptString(2, "")) + if err != nil { + return pushErr(err) + } + return pushList(keys) + } + return pushVal([]interface{}{}) + })) + settTbl.RawSetString("list", L.NewFunction(func(L *lua.LState) int { + if st := s.Settings(); st != nil { + keys, err := st.List(L.OptString(1, "")) + if err != nil { + return pushErr(err) + } + return pushList(keys) + } + return pushVal([]interface{}{}) + })) + settTbl.RawSetString("register_def", L.NewFunction(func(L *lua.LState) int { + if st := s.Settings(); st != nil { + tbl := L.CheckTable(1) + def := sdk.ConfigDef{ + Key: tbl.RawGetString("key").String(), + Default: luaValueToGo(tbl.RawGetString("default")), + Type: tbl.RawGetString("type").String(), + DisplayName: tbl.RawGetString("display_name").String(), + Description: tbl.RawGetString("description").String(), + Category: tbl.RawGetString("category").String(), + Min: float64(lua.LVAsNumber(tbl.RawGetString("min"))), + Max: float64(lua.LVAsNumber(tbl.RawGetString("max"))), + Step: float64(lua.LVAsNumber(tbl.RawGetString("step"))), + Required: lua.LVAsBool(tbl.RawGetString("required")), + Secret: lua.LVAsBool(tbl.RawGetString("secret")), + } + if opts := tbl.RawGetString("options"); opts != nil { + if ot, ok := opts.(*lua.LTable); ok { + ot.ForEach(func(_, v lua.LValue) { + def.Options = append(def.Options, v.String()) + }) + } + } + st.RegisterDef(def) + } + return pushNil() + })) + settTbl.RawSetString("defs", L.NewFunction(func(L *lua.LState) int { + if st := s.Settings(); st != nil { + return pushList(st.Defs(L.OptString(1, ""))) + } + return pushVal([]interface{}{}) + })) + settTbl.RawSetString("dump", L.NewFunction(func(L *lua.LState) int { + if st := s.Settings(); st != nil { + return pushVal(st.Dump()) + } + return pushVal(map[string]interface{}{}) + })) + settTbl.RawSetString("plugins", L.NewFunction(func(L *lua.LState) int { + if st := s.Settings(); st != nil { + return pushList(st.Plugins()) + } + return pushVal([]interface{}{}) + })) } func makeToolHandler(plg *luaPlugin, name string, fn *lua.LFunction) sdk.ToolHandler { @@ -265,12 +723,26 @@ func makeStageHandler(plg *luaPlugin, stage sdk.Stage, fn *lua.LFunction) sdk.St plg.mu.Lock() defer plg.mu.Unlock() L := plg.L - L.Push(fn) - L.Push(goValueToLua(L, map[string]interface{}{ + ctx := map[string]interface{}{ "raw_message": sc.RawMessage, "user_id": sc.UserID, + "group_id": sc.GroupID, "phase": string(sc.Phase), - })) + "llm_text": sc.LLMText, + "final_text": sc.FinalText, + "no_memory": sc.NoMemory, + } + if sc.Response != nil { + ctx["response"] = *sc.Response + } + if len(sc.ToolCalls) > 0 { + ctx["tool_calls"] = jsonToIface(sc.ToolCalls) + } + if len(sc.ToolResults) > 0 { + ctx["tool_results"] = jsonToIface(sc.ToolResults) + } + L.Push(fn) + L.Push(goValueToLua(L, ctx)) if err := L.PCall(1, 0, nil); err != nil { return fmt.Errorf("lua stage %s: %w", stage, err) } @@ -278,6 +750,98 @@ func makeStageHandler(plg *luaPlugin, stage sdk.Stage, fn *lua.LFunction) sdk.St } } +func parseStageScope(L *lua.LState) sdk.StageScope { + if L.GetTop() >= 3 && L.ToString(3) == "own_tools" { + return sdk.StageScopeOwnTools + } + return sdk.StageScopeGlobal +} + +func parseToolDef(L *lua.LState, defTbl *lua.LTable, plg *luaPlugin, name string) sdk.ToolDef { + goDef := sdk.ToolDef{Name: name, Plugin: plg.name} + goDef.Description = defTbl.RawGetString("description").String() + if v := defTbl.RawGetString("no_memory"); v != nil { + goDef.NoMemory = lua.LVAsBool(v) + } + if v := defTbl.RawGetString("cleaner"); v != nil && v.Type() == lua.LTFunction { + goDef.Cleaner = makeLuaCleaner(plg, v.(*lua.LFunction)) + } + if params := defTbl.RawGetString("parameters"); params != nil { + if pt, ok := params.(*lua.LTable); ok { + goDef.Parameters = make(map[string]interface{}) + pt.ForEach(func(k, v lua.LValue) { + goDef.Parameters[k.String()] = luaValueToGo(v) + }) + } + } + return goDef +} + +func parseChannelDef(L *lua.LState, defTbl *lua.LTable, plg *luaPlugin) sdk.ChannelDef { + chDef := sdk.ChannelDef{} + if v := defTbl.RawGetString("no_memory"); v != nil { + chDef.NoMemory = lua.LVAsBool(v) + } + if v := defTbl.RawGetString("cleaner"); v != nil && v.Type() == lua.LTFunction { + chDef.Cleaner = makeLuaCleaner(plg, v.(*lua.LFunction)) + } + return chDef +} + +// jsonToIface 通过 JSON 往返把任意 Go 值转换为 JSON 兼容的 interface{} 树。 +func jsonToIface(v interface{}) interface{} { + b, err := json.Marshal(v) + if err != nil { + return nil + } + var m interface{} + if err := json.Unmarshal(b, &m); err != nil { + return nil + } + return m +} + +// jsonToLuaValue 通过 JSON 往返把任意 Go 值(struct/slice/map)转换为 Lua 值, +// 语义与 C ABI 外部插件跨边界 JSON 序列化一致。 +func jsonToLuaValue(L *lua.LState, v interface{}) lua.LValue { + return goValueToLua(L, jsonToIface(v)) +} + +func makeLuaCleaner(plg *luaPlugin, fn *lua.LFunction) func(string) string { + return func(s string) string { + plg.mu.Lock() + defer plg.mu.Unlock() + L := plg.L + L.Push(fn) + L.Push(lua.LString(s)) + if err := L.PCall(1, 1, nil); err != nil { + return s + } + result := L.Get(-1) + L.Pop(1) + if str, ok := result.(lua.LString); ok { + return string(str) + } + return s + } +} + +func makeOutputHandler(plg *luaPlugin, fn *lua.LFunction) sdk.ToolHandler { + return func(args map[string]interface{}) (interface{}, error) { + plg.mu.Lock() + defer plg.mu.Unlock() + L := plg.L + L.Push(fn) + L.Push(goValueToLua(L, args)) + if err := L.PCall(1, 1, nil); err != nil { + return nil, fmt.Errorf("lua output channel: %w", err) + } + result := L.Get(-1) + L.Pop(1) + return luaValueToGo(result), nil + } +} + func (p *luaPlugin) Name() string { return p.name } func (p *luaPlugin) Start(s *sdk.PluginSDK) error { @@ -287,25 +851,40 @@ func (p *luaPlugin) Start(s *sdk.PluginSDK) error { replaceSDKReal(p.L, sdkTable, p, s) } - // 2) 批量注册加载期已暂存的 tool handler + // 2) 批量注册加载期已暂存的 tool / stage / channel handler p.mu.Lock() tools := make(map[string]*toolReg, len(p.tools)) for k, v := range p.tools { tools[k] = v } - stages := make(map[sdk.Stage]*lua.LFunction, len(p.stages)) + stages := make(map[sdk.Stage]*stageReg, len(p.stages)) for k, v := range p.stages { stages[k] = v } + outputChs := make(map[string]*outputChReg, len(p.outputChs)) + for k, v := range p.outputChs { + outputChs[k] = v + } + inputDefs := make(map[string]sdk.ChannelDef, len(p.inputDefs)) + for k, v := range p.inputDefs { + inputDefs[k] = v + } p.mu.Unlock() for toolName, reg := range tools { h := makeToolHandler(p, toolName, reg.handler) s.RegisterTool(toolName, reg.def, h) } - for stage, fn := range stages { - h := makeStageHandler(p, stage, fn) - s.RegisterStage(stage, h) + for stage, reg := range stages { + h := makeStageHandler(p, stage, reg.handler) + s.RegisterStage(stage, h, reg.scope) + } + for chName, reg := range outputChs { + h := makeOutputHandler(p, reg.handler) + s.RegisterOutputChannel(chName, reg.caps, reg.desc, reg.def, h) + } + for chName, def := range inputDefs { + s.RegisterInputChannel(chName, def) } // 3) 调用插件的 start(sdk) 回调 diff --git a/internal/plugin/lua_plugin_test.go b/internal/plugin/lua_plugin_test.go index 952eac1..f799530 100644 --- a/internal/plugin/lua_plugin_test.go +++ b/internal/plugin/lua_plugin_test.go @@ -4,6 +4,10 @@ import ( "os" "path/filepath" "testing" + + lua "github.com/yuin/gopher-lua" + internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" ) func TestTryLoadLua_Basic(t *testing.T) { @@ -114,3 +118,408 @@ end) } t.Logf("tool registered during load phase: OK") } + +func TestLuaChannelsAndMemoryDefs(t *testing.T) { + dir := t.TempDir() + + os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{ + "name": "chandefs", + "version": "1.0.0", + "entry": "main.lua" + }`), 0644) + + os.WriteFile(filepath.Join(dir, "main.lua"), []byte(` +local plugin = { name = "chandefs" } + +function plugin.start(sdk) + sdk.register_tool("mem_tool", { + description = "tool with memory defs", + parameters = {type = "object", properties = {}}, + no_memory = true, + cleaner = function(text) return "tool:" .. text end + }, function(args) + return {content = "ok"} + end) + + sdk.register_output_channel("out_chan", 1, "output channel", { + no_memory = true, + cleaner = function(text) return "out:" .. text end + }, function(args) + return {ok = true} + end) + + sdk.register_input_channel("in_chan", { + no_memory = false, + cleaner = function(text) return "in:" .. text end + }) +end + +function plugin.stop() end +return plugin +`), 0644) + + plg, err := tryLoadLua(dir, "chandefs", nil) + if err != nil { + t.Fatalf("tryLoadLua failed: %v", err) + } + + var gotTool *sdk.ToolDef + var gotOutputCh *sdk.ChannelDef + var gotOutputHandler sdk.ToolHandler + var gotInputDef *sdk.ChannelDef + + regTool := func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { + if name == "mem_tool" { + gotTool = &def + } + return nil + } + regOutput := func(name string, caps int, desc string, def sdk.ChannelDef, handler sdk.ToolHandler) error { + if name == "out_chan" { + gotOutputCh = &def + gotOutputHandler = handler + } + return nil + } + regInput := func(name string, def sdk.ChannelDef) error { + if name == "in_chan" { + gotInputDef = &def + } + return nil + } + + s := sdk.New("chandefs", sdk.SDKConfig{RegTool: regTool, RegOutput: regOutput, RegInput: regInput}) + + if err := plg.Start(s); err != nil { + t.Fatalf("Start failed: %v", err) + } + defer plg.Stop() + + if gotTool == nil { + t.Fatal("mem_tool not registered") + } + + if !gotTool.NoMemory { + t.Error("tool no_memory should be true") + } + if gotTool.Cleaner == nil { + t.Fatal("tool cleaner should not be nil") + } + if out := gotTool.Cleaner("abc"); out != "tool:abc" { + t.Errorf("tool cleaner result = %q, want %q", out, "tool:abc") + } + + if gotOutputCh == nil { + t.Fatal("output channel not registered") + } + if !gotOutputCh.NoMemory { + t.Error("output channel no_memory should be true") + } + if gotOutputCh.Cleaner == nil { + t.Fatal("output channel cleaner should not be nil") + } + if out := gotOutputCh.Cleaner("abc"); out != "out:abc" { + t.Errorf("output channel cleaner result = %q, want %q", out, "out:abc") + } + if gotOutputHandler == nil { + t.Fatal("output channel handler should not be nil") + } + res, err := gotOutputHandler(map[string]interface{}{"x": float64(1)}) + if err != nil { + t.Fatalf("output handler error: %v", err) + } + if m, ok := res.(map[string]interface{}); !ok || m["ok"] != true { + t.Errorf("output handler result = %#v, want {ok=true}", res) + } + + if gotInputDef == nil { + t.Fatal("input channel not registered") + } + if gotInputDef.NoMemory { + t.Error("input channel no_memory should be false") + } + if gotInputDef.Cleaner == nil { + t.Fatal("input channel cleaner should not be nil") + } + if out := gotInputDef.Cleaner("abc"); out != "in:abc" { + t.Errorf("input channel cleaner result = %q, want %q", out, "in:abc") + } +} + +func TestLuaChannelsLoadPhaseStash(t *testing.T) { + dir := t.TempDir() + + os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"name":"stashch","entry":"main.lua"}`), 0644) + os.WriteFile(filepath.Join(dir, "main.lua"), []byte(` +-- registered at load phase, before plugin.start(sdk) +sdk.register_output_channel("load_out", 2, "desc", {no_memory = true}, function(args) return {r = 1} end) +sdk.register_input_channel("load_in", {no_memory = true}) +return { name = "stashch", start = function(sdk) end, stop = function() end } +`), 0644) + + plg, err := tryLoadLua(dir, "stashch", nil) + if err != nil { + t.Fatalf("tryLoadLua failed: %v", err) + } + lp := plg.(*luaPlugin) + + lp.mu.Lock() + outCount := len(lp.outputChs) + inCount := len(lp.inputDefs) + outReg := lp.outputChs["load_out"] + inDef := lp.inputDefs["load_in"] + lp.mu.Unlock() + + if outCount != 1 { + t.Fatalf("expected 1 stashed output channel, got %d", outCount) + } + if inCount != 1 { + t.Fatalf("expected 1 stashed input channel, got %d", inCount) + } + if !outReg.def.NoMemory { + t.Error("stashed output channel should have NoMemory") + } + if !inDef.NoMemory { + t.Error("stashed input channel should have NoMemory") + } + + var gotOut, gotIn bool + s := sdk.New("stashch", sdk.SDKConfig{ + RegOutput: func(name string, caps int, desc string, def sdk.ChannelDef, handler sdk.ToolHandler) error { + if name == "load_out" { + if !def.NoMemory { + t.Error("output channel NoMemory lost through Start") + } + if handler == nil { + t.Error("output channel handler lost through Start") + } + gotOut = true + } + return nil + }, + RegInput: func(name string, def sdk.ChannelDef) error { + if name == "load_in" { + if !def.NoMemory { + t.Error("input channel NoMemory lost through Start") + } + gotIn = true + } + return nil + }, + }) + + if err := plg.Start(s); err != nil { + t.Fatalf("Start failed: %v", err) + } + defer plg.Stop() + + if !gotOut || !gotIn { + t.Fatalf("channels not registered through Start: out=%v in=%v", gotOut, gotIn) + } +} + +func TestLuaAlignedAPIs(t *testing.T) { + dir := t.TempDir() + + os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"name":"aligned","entry":"main.lua"}`), 0644) + os.WriteFile(filepath.Join(dir, "main.lua"), []byte(` +local plugin = { name = "aligned" } + +_G.res = {} +_G.stage_count = 0 +_G.stage_ctx = nil + +function plugin.start(sdk) + sdk.register_stage("before_toolcall", function(ctx) + _G.stage_count = _G.stage_count + 1 + _G.stage_ctx = ctx + end, "own_tools") + + local r, err = sdk.memory.recall("alice", 2) + _G.res.recall_ok = (r ~= nil and err == nil) + _G.res.recall_entities_type = type(r and r.entities) + + local r2, err2 = sdk.memory.recall({"alice"}, 2) + _G.res.recall_array_ok = (r2 ~= nil and err2 == nil) + _G.res.recall_array_err = err2 + + local _, cerr = sdk.memory.commit({{subject="a", relation="r", object="b"}}) + _G.res.commit_ok = (cerr == nil) + + local _, ierr = sdk.memory.introspect() + _G.res.introspect_ok = (ierr == nil) + local _, merr = sdk.memory.merge("a", "b") + _G.res.merge_ok = (merr == nil) + local _, perr = sdk.memory.purge({}, true) + _G.res.purge_ok = (perr == nil) + + local docs = sdk.doc.query("q", 3) + _G.res.doc_type = type(docs) + local _, dierr = sdk.doc.insert({id="d1", title="t", content="c"}) + _G.res.doc_insert_ok = (dierr == nil) + sdk.doc.remove("d1") + _G.res.doc_stats = type(sdk.doc.stats()) + + local kres, kerr = sdk.knowledge.search("q") + _G.res.kn_search_ok = (kerr == nil and type(kres) == "table") + local _, kadderr = sdk.knowledge.add("tag", "content") + _G.res.kn_add_ok = (kadderr == nil) + local klist = sdk.knowledge.list() + _G.res.kn_list_ok = (type(klist) == "table") + + local _, tmerr = sdk.text_memory.append({role="user", content="hello", timestamp=123, channel="c1"}) + _G.res.tm_ok = (tmerr == nil) + + local srcs = sdk.llm.list_sources() + _G.res.llm_list_type = type(srcs) + local _, serr = sdk.llm.set_source("default") + _G.res.llm_set_ok = (serr == nil) + _G.res.llm_cur_type = type(sdk.llm.current_source()) + + local p = sdk.social.get_person("alice") + _G.res.social_person = p + _G.res.social_net_type = type(sdk.social.get_network("alice", 1)) + local tr = sdk.social.get_trait("alice", "kind") + _G.res.social_trait = tr + _G.res.social_rels_type = type(sdk.social.get_relations("alice")) + _G.res.social_list_type = type(sdk.social.list_persons()) + + sdk.set_auto_restart(true) + _G.res.auto_restart_called = true + + sdk.settings.set_core("test_lua_key", "lv") + _G.res.core_val = sdk.settings.get_core("test_lua_key") + local cores = sdk.settings.list_core("test") + _G.res.core_list_has = (type(cores) == "table" and #cores > 0) + sdk.settings.set_plugin("other", "okey", "oval") + _G.res.plugin_val = sdk.settings.get_plugin("other", "okey") + _G.res.plugin_list = sdk.settings.list_plugin("other", "") + _G.res.sett_list_type = type(sdk.settings.list("")) + sdk.settings.register_def({key="def_key", type="string", display_name="DK", default="dv"}) + _G.res.def_val = sdk.get_setting("def_key") + _G.res.defs_type = type(sdk.settings.defs("")) + _G.res.dump_type = type(sdk.settings.dump()) + _G.res.plugins_type = type(sdk.settings.plugins()) + + sdk.log("info", "aligned api test done") +end + +function plugin.stop() end +return plugin +`), 0644) + + plg, err := tryLoadLua(dir, "aligned", nil) + if err != nil { + t.Fatalf("tryLoadLua failed: %v", err) + } + lp := plg.(*luaPlugin) + + var capturedHandler sdk.StageHandler + sett := sdk.NewSettings("aligned", internalConfig.NewConfigRegistry("")) + s := sdk.New("aligned", sdk.SDKConfig{ + Settings: sett, + RegStage: func(stage sdk.Stage, handler sdk.StageHandler) { + capturedHandler = handler + }, + }) + + if err := plg.Start(s); err != nil { + t.Fatalf("Start failed: %v", err) + } + defer plg.Stop() + + L := lp.L + res, ok := L.GetGlobal("res").(*lua.LTable) + if !ok { + t.Fatal("res global missing") + } + getBool := func(key string) bool { return lua.LVAsBool(res.RawGetString(key)) } + + if !getBool("recall_ok") { + t.Error("memory.recall should succeed (nil-safe)") + } + if res.RawGetString("recall_entities_type").String() != "table" { + t.Error("memory.recall entities should be a table") + } + if !getBool("recall_array_ok") { + t.Error("memory.recall with array query should succeed: " + res.RawGetString("recall_array_err").String()) + } + for _, k := range []string{"commit_ok", "introspect_ok", "merge_ok", "purge_ok", "doc_insert_ok", "kn_search_ok", "kn_add_ok", "kn_list_ok", "tm_ok", "llm_set_ok"} { + if !getBool(k) { + t.Errorf("%s failed", k) + } + } + if res.RawGetString("doc_type").String() != "table" || res.RawGetString("doc_stats").String() != "table" { + t.Error("doc.query/stats should return tables") + } + if res.RawGetString("llm_list_type").String() != "table" { + t.Error("llm list should return tables") + } + if res.RawGetString("llm_cur_type").String() != "nil" { + t.Error("llm.current_source should be nil when LLMAPI not wired") + } + trTbl, ok := res.RawGetString("social_trait").(*lua.LTable) + if !ok { + t.Fatal("social.get_trait should return a table") + } + if lua.LVAsBool(trTbl.RawGetString("found")) { + t.Error("social.get_trait found should be false when SocialAPI not wired") + } + if res.RawGetString("social_person").Type() != lua.LTNil { + t.Error("social.get_person should be nil when SocialAPI not wired") + } + if res.RawGetString("social_net_type").String() != "table" || + res.RawGetString("social_rels_type").String() != "table" || + res.RawGetString("social_list_type").String() != "table" { + t.Error("social list/network/relations should return tables") + } + if !getBool("auto_restart_called") { + t.Error("set_auto_restart should be callable") + } + if res.RawGetString("core_val").String() != "lv" { + t.Errorf("settings.get_core after set_core = %v, want lv", res.RawGetString("core_val")) + } + if !getBool("core_list_has") { + t.Error("settings.list_core should list set key") + } + if res.RawGetString("plugin_val").String() != "oval" { + t.Errorf("settings.get_plugin = %v, want oval", res.RawGetString("plugin_val")) + } + if res.RawGetString("def_val").String() != "dv" { + t.Errorf("register_def default should be readable via get_setting, got %v", res.RawGetString("def_val")) + } + for _, k := range []string{"sett_list_type", "defs_type", "dump_type", "plugins_type"} { + if res.RawGetString(k).String() != "table" { + t.Errorf("%s should be a table", k) + } + } + + // own_tools scope: 只有本插件工具触发 + if capturedHandler == nil { + t.Fatal("stage handler not registered") + } + capturedHandler(&sdk.StageContext{ + RawMessage: "hi", + LLMText: "llm text", + ToolCalls: []sdk.ToolCall{{Plugin: "aligned", Name: "x"}}, + }) + capturedHandler(&sdk.StageContext{ + ToolCalls: []sdk.ToolCall{{Plugin: "other", Name: "y"}}, + }) + if got := int(lua.LVAsNumber(L.GetGlobal("stage_count"))); got != 1 { + t.Fatalf("own_tools stage should fire only for own plugin, fired %d", got) + } + ctxTbl, ok := L.GetGlobal("stage_ctx").(*lua.LTable) + if !ok { + t.Fatal("stage_ctx global missing") + } + if ctxTbl.RawGetString("llm_text").String() != "llm text" { + t.Errorf("stage ctx llm_text = %v, want 'llm text'", ctxTbl.RawGetString("llm_text")) + } + if ctxTbl.RawGetString("tool_calls").Type() != lua.LTTable { + t.Error("stage ctx tool_calls should be a table") + } + if ctxTbl.RawGetString("raw_message").String() != "hi" { + t.Errorf("stage ctx raw_message = %v, want 'hi'", ctxTbl.RawGetString("raw_message")) + } +}