From fab58e709ad6332a93dc43e4335c3f7b91072eaa Mon Sep 17 00:00:00 2001 From: root Date: Sat, 4 Jul 2026 12:51:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20complete=20P0/P1/P2=20=E2=80=94=20WebUI?= =?UTF-8?q?=20SPA,=20OpenClaw=20sidecar+simulator,=20healthcheck=20auto-sc?= =?UTF-8?q?hed+perf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0: WebUI重构 - 完整 SPA 仪表盘 (7标签页), //go:embed dashboard.html P1: OpenClaw兼容 (三通道: SKILL.md / sidecar / simulator) - Node.js 模拟进程统一加载任意 OpenClaw 插件 - JSON-RPC 2.0 over stdio 协议, go:embed 内嵌 P2: Healthcheck 优化 - 定时自动执行 (startAutoCheck, 30min) - healthcheck_perf 性能监控工具 其他: agentcli/cmd 插件, integration_test, status.go, test_deepseek 清理, 多项 bug 修复 --- MILESTONE.md | 85 ++ cmd/homed/main.go | 39 +- docs/ARCHITECTURE.md | 18 +- docs/OVERVIEW.md | 3 +- go.mod | 9 +- internal/agent/api/provider.go | 16 + internal/agent/api/quickchat_test.go | 54 ++ internal/agent/core/agent.go | 15 +- internal/agent/core/status.go | 302 +++++++ internal/agent/io/channel.go | 16 + internal/plugins/agentcli/plugin.go | 814 ++++++++++++++++++ internal/plugins/agentcli/plugin_test.go | 385 +++++++++ internal/plugins/all.go | 3 + internal/plugins/cmd/plugin.go | 115 +++ internal/plugins/cmd/plugin_test.go | 261 ++++++ internal/plugins/healthcheck/plugin.go | 678 +++++++++++++++ internal/plugins/healthcheck/plugin_test.go | 350 ++++++++ internal/plugins/integration_test.go | 584 +++++++++++++ internal/plugins/openclaw/plugin.go | 183 +++- internal/plugins/openclaw/sidecar.go | 228 +++++ internal/plugins/openclaw/sidecar_test.go | 190 ++++ internal/plugins/openclaw/simulator/main.js | 294 +++++++ .../openclaw/testdata/echoplugin/main.js | 106 +++ internal/plugins/test_deepseek/plugin.go | 129 --- internal/plugins/test_deepseek/plugin_test.go | 246 ------ internal/plugins/webui/dashboard.html | 158 ++++ internal/plugins/webui/handler.go | 182 ++-- internal/plugins/webui/handler_test.go | 18 +- internal/plugins/webui/plugin.go | 37 +- internal/sdk/plugin.go | 16 + 30 files changed, 4956 insertions(+), 578 deletions(-) create mode 100644 MILESTONE.md create mode 100644 internal/agent/api/quickchat_test.go create mode 100644 internal/agent/core/status.go create mode 100644 internal/plugins/agentcli/plugin.go create mode 100644 internal/plugins/agentcli/plugin_test.go create mode 100644 internal/plugins/cmd/plugin.go create mode 100644 internal/plugins/cmd/plugin_test.go create mode 100644 internal/plugins/healthcheck/plugin.go create mode 100644 internal/plugins/healthcheck/plugin_test.go create mode 100644 internal/plugins/integration_test.go create mode 100644 internal/plugins/openclaw/sidecar.go create mode 100644 internal/plugins/openclaw/sidecar_test.go create mode 100644 internal/plugins/openclaw/simulator/main.js create mode 100644 internal/plugins/openclaw/testdata/echoplugin/main.js delete mode 100644 internal/plugins/test_deepseek/plugin.go delete mode 100644 internal/plugins/test_deepseek/plugin_test.go create mode 100644 internal/plugins/webui/dashboard.html diff --git a/MILESTONE.md b/MILESTONE.md new file mode 100644 index 0000000..1a79796 --- /dev/null +++ b/MILESTONE.md @@ -0,0 +1,85 @@ +# MILESTONE — 项目进展与路线图 + +## 已完成 + +### 插件系统基础 +- [x] agentcli 插件:6 个 PTY 终端工具(create/write/read/resize/close/list),完整按键映射 +- [x] cmd 插件:`cmd_run` 工具(command/timeout/workdir),8 单元测试 +- [x] timer 插件:定时器工具 +- [x] 12 个跨插件集成测试 + +### 非记忆 LLM 调用机制 +- [x] `StageContext.NoMemory` 标记 — agent 跳过 `emitMemoryCandidate` +- [x] `InjectTextNoMemoryTo` / `InjectTextSyncNoMemoryTo` — IOManager 层 +- [x] `InjectTextNoMemory` / `InjectTextSyncNoMemory` — PluginSDK 层 +- [x] `ProviderManager.QuickChat()` — 直连 Provider 的快捷调用 + +### LLM 驱动健康检查(`healthcheck` 插件) +- [x] `healthcheck_report` 工具 — LLM 上报测试结果 +- [x] `testLLMDriven()` — 独立工具循环:LLM 发现→调用→上报 +- [x] 动态排除本插件工具(`selfToolNames`),不硬编码插件名 +- [x] E2E 真实 DeepSeek API 测试通过(8 个真实工具全部测试通过) + +### 内核状态接口(`StatusProvider`) +- [x] `internal/agent/core/status.go` — `KernelStatus` 聚合快照 +- [x] `Agent.GetKernelStatus()` — 实现 `StatusProvider` 接口 +- [x] `healthcheck_kernel` 工具 — Agent 可自主查询内核状态 +- [x] WebUI `/api/v1/kernel` 端点 — 状态 JSON API +- [x] 所有子系统:plugins / tools / channels / memory / knowledge / documents / text_memory / social / skills / LLM / runtime / tracker + +### 技术债务清理 +- [x] 移除旧 `test_deepseek` 插件 +- [x] 修复 agentcli PTY readLoop 死锁(goroutine reader + close 顺序) +- [x] 修复 knowledge.Store.Delete 不存在方法 + +### P0 — WebUI 重构 +- [x] 完整的 SPA 仪表盘(7 标签页:概览/对话/插件/记忆/知识/设置/内核) +- [x] 深色主题,响应式布局 +- [x] 使用 `//go:embed dashboard.html` 替代硬编码 HTML 变量 +- [x] 全部 REST API 端点保持兼容 + +### P1 — OpenClaw 兼容插件(Go 插件 + Node.js sidecar + 模拟器架构) +- [x] 设计 Go ↔ Node.js 通信协议(JSON-RPC 2.0 over stdio) +- [x] Go 侧 sidecar 管理器(进程启动、心跳、重启、`waitReady`) +- [x] 工具发现(`tools/list`)和执行(`tools/call`)转译 +- [x] Node.js 测试插件 `echoplugin`(echo/add/ping 三个工具) +- [x] 5 个端到端测试(无 main.js 兜底、列表、调用 echo/add、不存在的工具、并发调用) +- [x] 现有 SKILL.md 加载路径保留(三通道:SKILL.md / sidecar / simulator) +- [x] Node.js OpenClaw 模拟进程(`simulator/main.js`):完整实现 `OpenClawPluginApi`,加载任意遵循 OpenClaw 插件标准的真实插件 +- [x] 模拟器内嵌于 Go 二进制(`//go:embed`),启动时自动提取 +- [x] 支持 `package.json#openclaw.extensions/runtimeExtensions` 入口发现,兼容 `.ts`→`.js` 编译回退 +- [x] 用 ClawHub 真实插件(chart-plot)验证端到端链路通过 + +### P2 — 进一步优化 +- [x] Healthcheck 定时自动执行(`startAutoCheck` goroutine,默认 30 分钟) +- [x] Agent 自主健康状态感知(通过 `healthcheck_kernel`) +- [x] 更多 WebUI 可视化面板(7 标签页 SPA) +- [x] 性能监控与告警(`healthcheck_perf` 工具 + `PerfData` 历史记录 100 条) + +--- + +## 当前状态 + +所有 P0 / P1 / P2 计划项已完成。项目处于维护和迭代阶段。 + +### 已注册的工具(14 个) +- `cmd_run` — 命令执行 +- `terminal_create / write / read / resize / close / list` — PTY 终端 +- `healthcheck` — 全面健康检查(含 LLM 驱动阶段) +- `healthcheck_plugins` — 列出已加载插件 +- `healthcheck_tools` — 列出所有已注册工具 +- `healthcheck_memory` — 图记忆系统测试 +- `healthcheck_report` — LLM 汇报接口 +- `healthcheck_kernel` — 内核状态快照 +- `healthcheck_perf` — 性能监控数据 +- `timer_set` — 定时器 + +### 内置插件(7 个) +- agentcli — PTY 终端管理 +- cli — Unix socket 通信 +- cmd — 命令执行 +- healthcheck — 健康检查 + 性能监控 + 自动调度 +- mcp — MCP 协议支持 +- openclaw — SKILL.md + Node.js sidecar 双通道 +- webui — HTTP 服务器 + SPA 仪表盘 +- timer — 定时器 diff --git a/cmd/homed/main.go b/cmd/homed/main.go index edacefe..64504ce 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -27,6 +27,7 @@ import ( "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli" + healthcheck "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck" openclaw "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw" webui "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" @@ -316,25 +317,8 @@ func main() { return nil }) - // 为内置插件注入内核依赖(各插件通过 init() 自注册工厂) - cli.DefaultSocket = *cliSocket - openclaw.SkillsDir = filepath.Join(cfg.Daemon.DataDir, "skills") - webui.Configure(*httpAddr, - sup, memDB, skMgr, luaVM, cfg, iom, textMem, ks, trk, cfgReg, pluginReg, evBus, - ) - - // Auto-create plugins directory (without hardcoding plugin names) - plgDir := filepath.Join(cfg.Daemon.DataDir, "plugins") - os.MkdirAll(plgDir, 0755) - - // Load all plugins — each scans its own dir and is loaded via factory or .so - if err := pluginReg.Load(plgDir); err != nil { - log.Printf("[homed] warning: load plugins: %v", err) - } - log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount()) - // ======================================================================== - // Agent Core + // Agent Core (需在插件加载前创建,因为插件 Configure 需要 StatusProvider) // ======================================================================== agent := agentCore.New(agentCore.AgentConfig{ @@ -377,6 +361,25 @@ func main() { EventBus: evBus, ThinkingEnabled: cfg.LLM.ThinkingEnabled, }) + + // 为内置插件注入内核依赖(各插件通过 init() 自注册工厂) + cli.DefaultSocket = *cliSocket + openclaw.SkillsDir = filepath.Join(cfg.Daemon.DataDir, "skills") + webui.Configure(*httpAddr, + sup, memDB, skMgr, luaVM, cfg, iom, textMem, ks, trk, cfgReg, pluginReg, evBus, agent, + ) + healthcheck.Configure(stageHost, iom, pluginReg, memDB, ks, docStore, providerMgr, agent) + + // Auto-create plugins directory (without hardcoding plugin names) + plgDir := filepath.Join(cfg.Daemon.DataDir, "plugins") + os.MkdirAll(plgDir, 0755) + + // Load all plugins — each scans its own dir and is loaded via factory or .so + if err := pluginReg.Load(plgDir); err != nil { + log.Printf("[homed] warning: load plugins: %v", err) + } + log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount()) + agent.Start() defer agent.Stop() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 864347a..f9b727d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -613,12 +613,22 @@ internal/ │ └── plugin.go — SKILL 插件解析 (OpenClaw 兼容) ├── plugins/ │ ├── all.go — 空白导入触发所有内置插件 init() -│ ├── timer/plugin.go — 定时器 (timer_set 工具 + 中断反馈) +│ ├── all_test.go — 集成测试(14 工具跨插件) +│ ├── agentcli/plugin.go — PTY 终端 (6 个 terminal_* 工具) │ ├── cli/plugin.go — CLI 插件 (Unix socket, InjectTextSync) -│ ├── openclaw/plugin.go — OpenClaw 兼容 (SKILL.md → SDK 工具注册) -│ └── webui/ — WebUI 插件 (HTTP 服务器 + 仪表盘) +│ ├── cmd/plugin.go — 命令执行 (cmd_run 工具) +│ ├── healthcheck/plugin.go — 健康检查 + 性能监控 + 自动调度 +│ ├── mcp/plugin.go — MCP 协议支持 +│ ├── openclaw/ — OpenClaw 兼容 +│ │ ├── plugin.go — SKILL.md + sidecar + simulator 三通道 +│ │ ├── sidecar.go — JSON-RPC over stdio 侧车管理 +│ │ └── simulator/ +│ │ └── main.js — OpenClaw 插件模拟器 (go:embed) +│ ├── timer/plugin.go — 定时器 (timer_set 工具 + 中断反馈) +│ └── webui/ — WebUI 插件 (HTTP 服务器 + SPA 仪表盘) │ ├── plugin.go -│ └── handler.go +│ ├── handler.go +│ └── dashboard.html — 嵌入式 SPA (go:embed) ├── memory/ │ ├── graph.go — SQLite 图数据库 │ ├── indexer.go — 图索引器 diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index 0123043..7d29c55 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -36,8 +36,9 @@ HomeAgent 是一个**持续运行的个人智能管家**。它像一个随时在 - 倒计时结束后它会主动通知你 ### 🔌 可扩展(插件) -- **Web 控制台** — 在浏览器中管理和配置 +- **Web 控制台** — 在浏览器中管理和配置(7 标签页 SPA) - **命令行** — 通过终端快速交互 +- **健康检查** — 自动检测系统各组件状态,LLM 驱动故障排查 - **更多能力** — 开发者可以写插件接入任何服务 ## 它是如何工作的?(简述) diff --git a/go.mod b/go.mod index e27f7ce..6c409fd 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,9 @@ module gitcode.com/JianFeeeee/HomeAgent go 1.19 require ( + github.com/gorilla/websocket v1.5.3 github.com/mattn/go-sqlite3 v1.14.22 + github.com/yanyiwu/gojieba v1.4.7 + github.com/yuin/gopher-lua v1.1.2 gopkg.in/yaml.v3 v3.0.1 ) - -require ( - github.com/gorilla/websocket v1.5.3 // indirect - github.com/yanyiwu/gojieba v1.4.7 // indirect - github.com/yuin/gopher-lua v1.1.2 // indirect -) diff --git a/internal/agent/api/provider.go b/internal/agent/api/provider.go index b410561..7c55903 100644 --- a/internal/agent/api/provider.go +++ b/internal/agent/api/provider.go @@ -605,6 +605,22 @@ func (m *ProviderManager) Default() Provider { return m.providers[m.default_] } +// QuickChat 向默认 LLM Provider 发送一条简短消息并返回回复。 +// 这是一个"非记忆"调用——直接通过 Provider HTTP 调用,不经过 Agent 的记忆/蒸馏管线。 +// 适用于健康检查、系统自检等不需要产生记忆碎片的场景。 +func (m *ProviderManager) QuickChat(ctx context.Context, prompt string) (*CompletionResponse, error) { + p := m.Default() + if p == nil { + return nil, fmt.Errorf("no default provider") + } + return p.Chat(ctx, &CompletionRequest{ + Messages: []Message{ + {Role: "user", Content: prompt}, + }, + MaxTokens: 128, + }) +} + func (m *ProviderManager) List() []string { m.mu.RLock() defer m.mu.RUnlock() diff --git a/internal/agent/api/quickchat_test.go b/internal/agent/api/quickchat_test.go new file mode 100644 index 0000000..b0e1ec6 --- /dev/null +++ b/internal/agent/api/quickchat_test.go @@ -0,0 +1,54 @@ +package api + +import ( + "context" + "os" + "testing" + "time" + + luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua" +) + +func TestQuickChatWithRealKey(t *testing.T) { + apiKey := os.Getenv("DEEPSEEK_API_KEY") + if apiKey == "" { + t.Skip("DEEPSEEK_API_KEY not set — skipping real LLM test") + } + + tmpDir := t.TempDir() + + vm := luaVM.NewVM(tmpDir + "/adapters") + if err := vm.Start(); err != nil { + t.Fatal(err) + } + defer vm.Stop() + + pm := NewProviderManager() + pm.Register("deepseek", NewLuaAdaptedProvider(BaseConfig{ + Model: "deepseek-v4-flash", + BaseURL: "https://api.deepseek.com", + APIKey: apiKey, + }, vm, "deepseek")) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + start := time.Now() + resp, err := pm.QuickChat(ctx, "请回复'OK',不要输出其他内容") + elapsed := time.Since(start) + + if err != nil { + t.Fatalf("QuickChat failed: %v", err) + } + + if resp.Content == "" { + t.Fatal("empty response") + } + + t.Logf("Response: %q", resp.Content) + t.Logf("Time: %v", elapsed.Round(time.Millisecond)) + if resp.TokenUsage.Total > 0 { + t.Logf("Tokens: %d (prompt %d + completion %d)", + resp.TokenUsage.Total, resp.TokenUsage.Prompt, resp.TokenUsage.Completion) + } +} diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index d1a992d..c391358 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -93,6 +93,9 @@ type Agent struct { // 模型思考模式(thinking/reasoning) thinkingEnabled bool + + // 启动时间 + startTime time.Time } type AgentConfig struct { @@ -135,6 +138,7 @@ func New(cfg AgentConfig) *Agent { } return &Agent{ id: cfg.ID, + startTime: time.Now(), provider: cfg.Provider, providerManager: cfg.ProviderManager, io: cfg.IO, @@ -291,8 +295,14 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) { return } + noMemory := false + if v, ok := evt.Payload["no_memory"].(bool); ok { + noMemory = v + } + // === Stage: on_input — 消息到达,插件可拦截 === stageCtx := a.stageCtxFromInput(input, evt.Source, "") + stageCtx.NoMemory = noMemory a.publishEvent(events.EventRawInput, map[string]interface{}{ "content": input, "source": evt.Source, @@ -337,7 +347,9 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) { a.emitResponse(evt, response) - a.emitMemoryCandidate(evt.Source, input, response, toolsUsed) + if !stageCtx.NoMemory { + a.emitMemoryCandidate(evt.Source, input, response, toolsUsed) + } } func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) { @@ -1825,7 +1837,6 @@ func (a *Agent) processConsolidation(input string) { ToolsUsed: toolsUsed, }) _ = a.context.Prune(response, a.maxContextSize, a.docStore) - // 只写入记忆,不发外部输出 a.emitMemoryCandidate("system", input, response, toolsUsed) log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed) } diff --git a/internal/agent/core/status.go b/internal/agent/core/status.go new file mode 100644 index 0000000..e54c27e --- /dev/null +++ b/internal/agent/core/status.go @@ -0,0 +1,302 @@ +package core + +import ( + "fmt" + "runtime" + "time" + + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/social" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" + "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" + "gitcode.com/JianFeeeee/HomeAgent/internal/skill" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" + "gitcode.com/JianFeeeee/HomeAgent/internal/tracker" +) + +// StatusProvider 内核状态查询接口。插件通过此接口查看内核运行动态。 +type StatusProvider interface { + GetKernelStatus() *KernelStatus +} + +// KernelStatus 内核各子系统运行状态的聚合快照。 +type KernelStatus struct { + Uptime string `json:"uptime"` + StartTime string `json:"start_time"` + + AgentID string `json:"agent_id"` + + Plugins []PluginInfo `json:"plugins"` + Tools []sdk.ToolDef `json:"tools"` + Channels []ChannelInfo `json:"channels"` + + Memory MemoryStatus `json:"memory"` + Knowledge KnowledgeStatus `json:"knowledge"` + Documents DocumentStatus `json:"documents"` + TextMemory TextMemoryStatus `json:"text_memory"` + Social SocialStatus `json:"social"` + Skills SkillsStatus `json:"skills"` + + LLM LLMStatus `json:"llm"` + + Context ContextStatus `json:"context"` + + Runtime RuntimeStatus `json:"runtime"` + + Tracker TrackerStatus `json:"tracker"` +} + +type PluginInfo struct { + Name string `json:"name"` + Loaded bool `json:"loaded"` +} + +type ChannelInfo struct { + Name string `json:"name"` + Type string `json:"type"` + Ready bool `json:"ready"` +} + +func channelInfoFromIO(ch agentIO.ChannelInfo) ChannelInfo { + return ChannelInfo{ + Name: ch.Name, + Type: fmt.Sprintf("%d", ch.Type), + Ready: true, + } +} + +type MemoryStatus struct { + Available bool `json:"available"` + EntityCount int `json:"entity_count,omitempty"` + RelationCount int `json:"relation_count,omitempty"` + EntityTypes int `json:"entity_types,omitempty"` +} + +type KnowledgeStatus struct { + Available bool `json:"available"` + ItemCount int `json:"item_count,omitempty"` + Items []string `json:"items,omitempty"` +} + +type DocumentStatus struct { + Available bool `json:"available"` + DocCount int `json:"doc_count,omitempty"` + VectorCount int `json:"vector_count,omitempty"` +} + +type TextMemoryStatus struct { + Available bool `json:"available"` + FileCount int `json:"file_count,omitempty"` +} + +type SocialStatus struct { + Available bool `json:"available"` + PersonCount int `json:"person_count,omitempty"` +} + +type SkillsStatus struct { + Available bool `json:"available"` + SkillList []string `json:"skill_list,omitempty"` +} + +type LLMStatus struct { + Available bool `json:"available"` + Provider string `json:"provider,omitempty"` + Sources int `json:"sources,omitempty"` +} + +type ContextStatus struct { + EventCount int `json:"event_count,omitempty"` +} + +type RuntimeStatus struct { + Goroutines int `json:"goroutines"` + MemoryMB int64 `json:"memory_mb"` + GoVersion string `json:"go_version"` +} + +type TrackerStatus struct { + Available bool `json:"available"` + Dir string `json:"dir,omitempty"` +} + +// collectKernelStatus 聚合内核各子系统状态快照。 +// 接收所有子系统引用(均为可选——nil 表示不可用),返回统一的状态报告。 +func collectKernelStatus( + startTime time.Time, + agentID string, + providerName string, + sourceCount int, + stageHost *StageHost, + iom *agentIO.IOManager, + pluginReg *plugin.Registry, + memDB *memory.GraphDB, + ks interface{ List() []string }, + docStore *document.Store, + textMem *text.Memory, + socialStore *social.SocialStore, + skMgr *skill.Manager, + trk *tracker.Tracker, +) *KernelStatus { + status := &KernelStatus{ + Uptime: time.Since(startTime).Round(time.Second).String(), + StartTime: startTime.Format(time.RFC3339), + AgentID: agentID, + Runtime: RuntimeStatus{ + Goroutines: runtime.NumGoroutine(), + GoVersion: runtime.Version(), + }, + LLM: LLMStatus{ + Available: providerName != "", + Provider: providerName, + Sources: sourceCount, + }, + } + + // Plugins + if pluginReg != nil { + names := pluginReg.List() + for _, n := range names { + status.Plugins = append(status.Plugins, PluginInfo{Name: n, Loaded: true}) + } + } + + // Tools + if stageHost != nil { + status.Tools = stageHost.GetToolDefs() + } + + // Channels + if iom != nil { + for _, ch := range iom.ListChannels() { + status.Channels = append(status.Channels, channelInfoFromIO(ch)) + } + } + + // Graph memory + if memDB != nil { + status.Memory.Available = true + if info, err := memDB.Introspect(); err == nil { + if ec, ok := info["entity_count"].(int); ok { + status.Memory.EntityCount = ec + } + if rc, ok := info["relation_count"].(int); ok { + status.Memory.RelationCount = rc + } + if et, ok := info["entity_type_count"].(int); ok { + status.Memory.EntityTypes = et + } + } + } + + // Knowledge + if ks != nil { + status.Knowledge.Available = true + status.Knowledge.Items = ks.List() + status.Knowledge.ItemCount = len(status.Knowledge.Items) + } + + // Documents + if docStore != nil { + status.Documents.Available = true + stats := docStore.Stats() + if dc, ok := stats["doc_count"].(int); ok { + status.Documents.DocCount = dc + } + if vc, ok := stats["vector_count"].(int); ok { + status.Documents.VectorCount = vc + } + } + + // Text memory + if textMem != nil { + status.TextMemory.Available = true + status.TextMemory.FileCount = textMem.FileCount() + } + + // Social + if socialStore != nil { + status.Social.Available = true + if persons, err := socialStore.ListPersons(); err == nil { + status.Social.PersonCount = len(persons) + } + } + + // Skills + if skMgr != nil { + status.Skills.Available = true + skills := skMgr.List() + status.Skills.SkillList = make([]string, len(skills)) + for i, sk := range skills { + status.Skills.SkillList[i] = sk.Name + } + } + + // Tracker + if trk != nil { + status.Tracker.Available = true + status.Tracker.Dir = trk.MergeDir() + } + + // Memory + var m runtime.MemStats + runtime.ReadMemStats(&m) + status.Runtime.MemoryMB = int64(m.Alloc / 1024 / 1024) + + return status +} + +// GetKernelStatus 返回 Agent 驱动的内核状态快照。 +func (a *Agent) GetKernelStatus() *KernelStatus { + providerName := "" + sourceCount := 0 + if a.providerManager != nil { + sourceCount = len(a.providerManager.List()) + } + if a.provider != nil { + providerName = a.provider.Name() + } + + var textMem *text.Memory + if a.textMem != nil { + textMem = a.textMem + } + + var socialStore *social.SocialStore + if a.social != nil { + socialStore = a.social + } + + var skMgr *skill.Manager + if a.skills != nil { + skMgr = a.skills + } + + var trk *tracker.Tracker + if a.tracker != nil { + trk = a.tracker + } + + ks := collectKernelStatus( + a.startTime, + string(a.id), + providerName, + sourceCount, + a.stageHost, + a.io, + a.pluginReg, + a.memory, + a.knowledge, + a.docStore, + textMem, + socialStore, + skMgr, + trk, + ) + + return ks +} + +var _ StatusProvider = (*Agent)(nil) diff --git a/internal/agent/io/channel.go b/internal/agent/io/channel.go index 7afcd2f..589582b 100644 --- a/internal/agent/io/channel.go +++ b/internal/agent/io/channel.go @@ -241,6 +241,22 @@ func (m *IOManager) InjectTextTo(source, outputChannel, text string) { }) } +// InjectTextNoMemoryTo 注入文本输入(不产生记忆)并指定输出通道 +func (m *IOManager) InjectTextNoMemoryTo(source, outputChannel, text string) { + m.InjectInputTo(source, outputChannel, "text", map[string]interface{}{ + "content": text, + "no_memory": true, + }) +} + +// InjectTextSyncNoMemoryTo 注入文本输入(同步等待,不产生记忆)并指定输出通道 +func (m *IOManager) InjectTextSyncNoMemoryTo(source, outputChannel, text string) *OutputEvent { + return m.InjectInputSyncTo(source, outputChannel, "text", map[string]interface{}{ + "content": text, + "no_memory": true, + }) +} + // InjectInterrupt 向中断通道发送输入 func (m *IOManager) InjectInterrupt(source, channel string, payload map[string]interface{}) { if payload == nil { diff --git a/internal/plugins/agentcli/plugin.go b/internal/plugins/agentcli/plugin.go new file mode 100644 index 0000000..ff9b653 --- /dev/null +++ b/internal/plugins/agentcli/plugin.go @@ -0,0 +1,814 @@ +package agentcli + +import ( + "bytes" + "fmt" + "log" + "os" + "os/exec" + "strings" + "sync" + "syscall" + "time" + "unsafe" + + "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// PTY ioctl constants for Linux +const ( + TIOCGPTN = 0x80045430 + TIOCSPTLCK = 0x40045431 + TIOCSWINSZ = 0x5414 +) + +const ( + DefaultTimeout = 5 * time.Minute + ReadBufSize = 4096 + MaxOutputBuffer = 128 * 1024 + NotifyOutputDelay = 500 * time.Millisecond +) + +type winsize struct { + Row uint16 + Col uint16 + XPixel uint16 + YPixel uint16 +} + +func ioctl(fd, cmd uintptr, ptr unsafe.Pointer) error { + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, fd, cmd, uintptr(ptr)) + if errno != 0 { + return errno + } + return nil +} + +func openPty() (master *os.File, slave *os.File, err error) { + mfd, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY, 0) + if err != nil { + return nil, nil, fmt.Errorf("open /dev/ptmx: %w", err) + } + master = os.NewFile(uintptr(mfd), "/dev/ptmx") + + var unlock int32 + if err := ioctl(uintptr(mfd), TIOCSPTLCK, unsafe.Pointer(&unlock)); err != nil { + master.Close() + return nil, nil, fmt.Errorf("TIOCSPTLCK: %w", err) + } + + var ptyno int32 + if err := ioctl(uintptr(mfd), TIOCGPTN, unsafe.Pointer(&ptyno)); err != nil { + master.Close() + return nil, nil, fmt.Errorf("TIOCGPTN: %w", err) + } + + slavePath := fmt.Sprintf("/dev/pts/%d", ptyno) + sfd, err := syscall.Open(slavePath, syscall.O_RDWR|syscall.O_NOCTTY, 0) + if err != nil { + master.Close() + return nil, nil, fmt.Errorf("open slave %s: %w", slavePath, err) + } + slave = os.NewFile(uintptr(sfd), slavePath) + + return master, slave, nil +} + +type TerminalSession struct { + id string + cmd *exec.Cmd + master *os.File + slave *os.File + mu sync.Mutex + buf bytes.Buffer + createdAt time.Time + timeout time.Duration + closed bool + stopCh chan struct{} + done chan struct{} +} + +func (t *TerminalSession) Write(input string) (int, error) { + return t.master.WriteString(input) +} + +func (t *TerminalSession) Read(buf []byte) (int, error) { + return t.master.Read(buf) +} + +func (t *TerminalSession) ReadOutput() string { + t.mu.Lock() + defer t.mu.Unlock() + return t.buf.String() +} + +func (t *TerminalSession) ReadAndClearOutput() string { + t.mu.Lock() + defer t.mu.Unlock() + s := t.buf.String() + t.buf.Reset() + return s +} + +func (t *TerminalSession) appendOutput(data []byte) { + t.mu.Lock() + defer t.mu.Unlock() + if t.buf.Len()+len(data) > MaxOutputBuffer { + excess := t.buf.Len() + len(data) - MaxOutputBuffer + if t.buf.Len() > excess { + t.buf.Next(excess) + } else { + t.buf.Reset() + } + } + t.buf.Write(data) +} + +func (t *TerminalSession) Resize(rows, cols uint16) error { + ws := winsize{Row: rows, Col: cols} + if err := ioctl(uintptr(t.master.Fd()), TIOCSWINSZ, unsafe.Pointer(&ws)); err != nil { + return fmt.Errorf("TIOCSWINSZ: %w", err) + } + return nil +} + +func (t *TerminalSession) Close() { + t.mu.Lock() + if t.closed { + t.mu.Unlock() + return + } + t.closed = true + t.mu.Unlock() + + close(t.stopCh) + t.master.Close() + <-t.done + + t.slave.Close() + t.cmd.Process.Kill() +} + +func (t *TerminalSession) IsExpired() bool { + return time.Since(t.createdAt) >= t.timeout +} + +type Plugin struct { + name string + mu sync.Mutex + wg sync.WaitGroup + stopCh chan struct{} + sessions map[string]*TerminalSession + nextID int +} + +func init() { + plugin.RegisterFactory("agentcli", func(name string, config map[string]interface{}) (sdk.Plugin, error) { + return New(name), nil + }) +} + +func New(name string) *Plugin { + return &Plugin{ + name: name, + stopCh: make(chan struct{}), + sessions: make(map[string]*TerminalSession), + } +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + s.RegisterTool("terminal_create", sdk.ToolDef{ + Name: "terminal_create", + Description: "创建一个新的交互式终端会话。返回终端 ID,后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "command": map[string]interface{}{ + "type": "string", + "description": "要执行的命令(默认 bash)。如需运行特定程序直接传入即可,例如:vim /tmp/test.txt", + }, + "timeout": map[string]interface{}{ + "type": "string", + "description": "终端自动关闭时间,例如 5m, 10m, 30m, 1h(默认 5m)", + }, + "rows": map[string]interface{}{ + "type": "integer", + "description": "终端行数(默认 24)", + }, + "cols": map[string]interface{}{ + "type": "integer", + "description": "终端列数(默认 80)", + }, + }, + }, + }, func(args map[string]interface{}) (interface{}, error) { + return p.handleCreate(s, args) + }) + + s.RegisterTool("terminal_write", sdk.ToolDef{ + Name: "terminal_write", + Description: "向指定终端发送输入。支持普通文本和特殊键(通过 key 参数传入)。特殊键包括:enter, tab, escape, ctrl_a~ctrl_z, alt_a~alt_z, f1~f12, up, down, left, right, home, end, backspace, delete, page_up, page_down。普通文本传入 input 参数即可。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{ + "type": "string", + "description": "终端 ID,来自 terminal_create 的返回值", + }, + "input": map[string]interface{}{ + "type": "string", + "description": "要发送的文本内容(普通文字直接输入)", + }, + "key": map[string]interface{}{ + "type": "string", + "description": "特殊按键:enter, tab, escape, ctrl_a~ctrl_z, alt_a~alt_z, f1~f12, up, down, left, right, home, end, backspace, delete, page_up, page_down", + }, + }, + "required": []string{"id"}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + return p.handleWrite(s, args) + }) + + s.RegisterTool("terminal_read", sdk.ToolDef{ + Name: "terminal_read", + Description: "读取指定终端的当前屏幕内容。返回自上次读取以来的新输出。如需持续监控请多次调用。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{ + "type": "string", + "description": "终端 ID", + }, + "clear": map[string]interface{}{ + "type": "boolean", + "description": "读取后是否清除缓冲区(默认 true)", + }, + }, + "required": []string{"id"}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + return p.handleRead(args) + }) + + s.RegisterTool("terminal_resize", sdk.ToolDef{ + Name: "terminal_resize", + Description: "调整指定终端的尺寸(行数和列数)。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{ + "type": "string", + "description": "终端 ID", + }, + "rows": map[string]interface{}{ + "type": "integer", + "description": "行数", + }, + "cols": map[string]interface{}{ + "type": "integer", + "description": "列数", + }, + }, + "required": []string{"id"}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + return p.handleResize(args) + }) + + s.RegisterTool("terminal_close", sdk.ToolDef{ + Name: "terminal_close", + Description: "关闭指定终端会话。释放资源。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{ + "type": "string", + "description": "终端 ID", + }, + }, + "required": []string{"id"}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + return p.handleClose(args) + }) + + s.RegisterTool("terminal_list", sdk.ToolDef{ + Name: "terminal_list", + Description: "列出所有活跃的终端会话及其状态。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + return p.handleList() + }) + + p.wg.Add(1) + go p.cleanupLoop(s) + + return nil +} + +func (p *Plugin) Stop() error { + close(p.stopCh) + p.wg.Wait() + + p.mu.Lock() + for _, t := range p.sessions { + t.Close() + } + p.sessions = nil + p.mu.Unlock() + + return nil +} + +func (p *Plugin) nextIDLocked() string { + p.nextID++ + return fmt.Sprintf("term_%d", p.nextID) +} + +func (p *Plugin) handleCreate(s *sdk.PluginSDK, args map[string]interface{}) (interface{}, error) { + command, _ := args["command"].(string) + if command == "" { + command = "bash" + } + + timeoutStr, _ := args["timeout"].(string) + timeout := DefaultTimeout + if timeoutStr != "" { + if d, err := time.ParseDuration(timeoutStr); err == nil { + timeout = d + } + } + + rows := uint16(24) + cols := uint16(80) + if r, ok := args["rows"].(float64); ok && r > 0 { + rows = uint16(r) + } + if c, ok := args["cols"].(float64); ok && c > 0 { + cols = uint16(c) + } + + master, slave, err := openPty() + if err != nil { + return map[string]interface{}{"error": fmt.Sprintf("创建 PTY 失败: %v", err)}, nil + } + + cmd := exec.Command("sh", "-c", command) + cmd.Stdin = slave + cmd.Stdout = slave + cmd.Stderr = slave + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + Setctty: true, + Ctty: 0, + } + + if err := cmd.Start(); err != nil { + master.Close() + slave.Close() + return map[string]interface{}{"error": fmt.Sprintf("启动命令失败: %v", err)}, nil + } + + slave.Close() + + session := &TerminalSession{ + cmd: cmd, + master: master, + createdAt: time.Now(), + timeout: timeout, + stopCh: make(chan struct{}), + done: make(chan struct{}), + } + + p.mu.Lock() + id := p.nextIDLocked() + session.id = id + p.sessions[id] = session + + ws := winsize{Row: rows, Col: cols} + ioctl(uintptr(master.Fd()), TIOCSWINSZ, unsafe.Pointer(&ws)) + p.mu.Unlock() + + p.wg.Add(1) + go p.readLoop(session, s) + + log.Printf("[agentcli] created terminal %s: command=%q timeout=%v rows=%d cols=%d", id, command, timeout, rows, cols) + + return map[string]interface{}{ + "id": id, + "status": "created", + "command": command, + "timeout": timeout.String(), + "rows": rows, + "cols": cols, + }, nil +} + +func (p *Plugin) handleWrite(s *sdk.PluginSDK, args map[string]interface{}) (interface{}, error) { + id, _ := args["id"].(string) + if id == "" { + return map[string]interface{}{"error": "id is required"}, nil + } + + p.mu.Lock() + session, ok := p.sessions[id] + p.mu.Unlock() + if !ok { + return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil + } + + input, _ := args["input"].(string) + key, _ := args["key"].(string) + + var data []byte + + if key != "" { + b, err := mapKey(key) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + data = b + } else if input != "" { + data = []byte(input) + } else { + return map[string]interface{}{"error": "需要提供 input 或 key 参数"}, nil + } + + n, err := session.Write(string(data)) + if err != nil { + return map[string]interface{}{"error": fmt.Sprintf("写入失败: %v", err)}, nil + } + + return map[string]interface{}{ + "status": "ok", + "bytes": n, + "terminal": id, + }, nil +} + +func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) { + id, _ := args["id"].(string) + if id == "" { + return map[string]interface{}{"error": "id is required"}, nil + } + + clear := true + if v, ok := args["clear"].(bool); ok { + clear = v + } + + p.mu.Lock() + session, ok := p.sessions[id] + p.mu.Unlock() + if !ok { + return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil + } + + var output string + if clear { + output = session.ReadAndClearOutput() + } else { + output = session.ReadOutput() + } + + if output == "" { + output = "[终端无新输出]" + } + + return map[string]interface{}{ + "status": "ok", + "terminal": id, + "output": output, + "running": session.cmd.ProcessState == nil || !session.cmd.ProcessState.Exited(), + "uptime": time.Since(session.createdAt).String(), + }, nil +} + +func (p *Plugin) handleResize(args map[string]interface{}) (interface{}, error) { + id, _ := args["id"].(string) + if id == "" { + return map[string]interface{}{"error": "id is required"}, nil + } + + rows, okRows := args["rows"].(float64) + cols, okCols := args["cols"].(float64) + if !okRows || !okCols { + return map[string]interface{}{"error": "rows 和 cols 为必填"}, nil + } + + p.mu.Lock() + session, ok := p.sessions[id] + p.mu.Unlock() + if !ok { + return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil + } + + if err := session.Resize(uint16(rows), uint16(cols)); err != nil { + return map[string]interface{}{"error": fmt.Sprintf("调整尺寸失败: %v", err)}, nil + } + + return map[string]interface{}{ + "status": "ok", + "terminal": id, + "rows": rows, + "cols": cols, + }, nil +} + +func (p *Plugin) handleClose(args map[string]interface{}) (interface{}, error) { + id, _ := args["id"].(string) + if id == "" { + return map[string]interface{}{"error": "id is required"}, nil + } + + p.mu.Lock() + session, ok := p.sessions[id] + if ok { + delete(p.sessions, id) + } + p.mu.Unlock() + + if !ok { + return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil + } + + session.Close() + log.Printf("[agentcli] closed terminal %s", id) + + return map[string]interface{}{ + "status": "closed", + "terminal": id, + }, nil +} + +func (p *Plugin) handleList() (interface{}, error) { + p.mu.Lock() + defer p.mu.Unlock() + + type termInfo struct { + ID string `json:"id"` + Command string `json:"command"` + Uptime string `json:"uptime"` + ExpiresIn string `json:"expires_in"` + Running bool `json:"running"` + } + + var terms []termInfo + for _, t := range p.sessions { + running := t.cmd.ProcessState == nil || !t.cmd.ProcessState.Exited() + remaining := t.timeout - time.Since(t.createdAt) + if remaining < 0 { + remaining = 0 + } + terms = append(terms, termInfo{ + ID: t.id, + Uptime: time.Since(t.createdAt).Round(time.Second).String(), + ExpiresIn: remaining.Round(time.Second).String(), + Running: running, + }) + } + + if terms == nil { + terms = []termInfo{} + } + + return map[string]interface{}{ + "status": "ok", + "count": len(terms), + "terminals": terms, + }, nil +} + +func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) { + defer p.wg.Done() + defer close(t.done) + + buf := make([]byte, ReadBufSize) + lastNotify := time.Now() + pollInterval := 200 * time.Millisecond + + readCh := make(chan readResult, 4) + go p.reader(t, buf, readCh) + + for { + if t.IsExpired() { + log.Printf("[agentcli] terminal %s expired after %v", t.id, t.timeout) + s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已超时关闭(%s)]", t.id, t.timeout)) + p.mu.Lock() + delete(p.sessions, t.id) + p.mu.Unlock() + return + } + + if t.cmd.ProcessState != nil && t.cmd.ProcessState.Exited() { + s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的进程已退出]", t.id)) + p.mu.Lock() + delete(p.sessions, t.id) + p.mu.Unlock() + return + } + + select { + case <-t.stopCh: + return + case r := <-readCh: + if r.err != nil { + return + } + if r.n > 0 { + data := make([]byte, r.n) + copy(data, buf[:r.n]) + t.appendOutput(data) + if time.Since(lastNotify) > NotifyOutputDelay { + preview := string(data) + if len(preview) > 100 { + preview = preview[:100] + } + preview = sanitizePreview(preview) + s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 有新输出]\n%s", t.id, preview)) + lastNotify = time.Now() + } + } + case <-time.After(pollInterval): + } + } +} + +type readResult struct { + n int + err error +} + +func (p *Plugin) reader(t *TerminalSession, buf []byte, ch chan<- readResult) { + for { + n, err := t.master.Read(buf) + select { + case ch <- readResult{n, err}: + case <-t.stopCh: + return + } + if err != nil { + return + } + } +} + +func (p *Plugin) cleanupLoop(s *sdk.PluginSDK) { + defer p.wg.Done() + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + p.mu.Lock() + for id, t := range p.sessions { + if t.IsExpired() { + log.Printf("[agentcli] cleanup: terminal %s expired", id) + delete(p.sessions, id) + go func(term *TerminalSession) { + term.Close() + }(t) + } + if t.cmd.ProcessState != nil && t.cmd.ProcessState.Exited() { + log.Printf("[agentcli] cleanup: terminal %s process exited", id) + delete(p.sessions, id) + go func(term *TerminalSession) { + term.Close() + }(t) + } + } + p.mu.Unlock() + } + } +} + +func mapKey(key string) ([]byte, error) { + key = strings.ToLower(key) + switch key { + case "enter": + return []byte{0x0D}, nil + case "tab": + return []byte{0x09}, nil + case "escape", "esc": + return []byte{0x1B}, nil + case "backspace": + return []byte{0x7F}, nil + case "delete": + return []byte{0x1B, 0x5B, 0x33, 0x7E}, nil + case "home": + return []byte{0x1B, 0x5B, 0x48}, nil + case "end": + return []byte{0x1B, 0x5B, 0x46}, nil + case "page_up": + return []byte{0x1B, 0x5B, 0x35, 0x7E}, nil + case "page_down": + return []byte{0x1B, 0x5B, 0x36, 0x7E}, nil + case "up": + return []byte{0x1B, 0x5B, 0x41}, nil + case "down": + return []byte{0x1B, 0x5B, 0x42}, nil + case "right": + return []byte{0x1B, 0x5B, 0x43}, nil + case "left": + return []byte{0x1B, 0x5B, 0x44}, nil + } + + if strings.HasPrefix(key, "ctrl_") && len(key) == 6 { + c := key[5] + if c >= 'a' && c <= 'z' { + return []byte{byte(c - 'a' + 1)}, nil + } + } + + if strings.HasPrefix(key, "alt_") && len(key) == 5 { + c := key[4] + if c >= 'a' && c <= 'z' { + return []byte{0x1B, c}, nil + } + } + + if strings.HasPrefix(key, "f") && len(key) <= 4 { + var n int + if _, err := fmt.Sscanf(key, "f%d", &n); err == nil && n >= 1 && n <= 12 { + return functionKeyCode(n), nil + } + } + + return nil, fmt.Errorf("不支持的特殊按键: %s", key) +} + +func functionKeyCode(n int) []byte { + switch n { + case 1: + return []byte{0x1B, 0x5B, 0x50} + case 2: + return []byte{0x1B, 0x5B, 0x51} + case 3: + return []byte{0x1B, 0x5B, 0x52} + case 4: + return []byte{0x1B, 0x5B, 0x53} + case 5: + return []byte{0x1B, 0x5B, 0x31, 0x35, 0x7E} + case 6: + return []byte{0x1B, 0x5B, 0x31, 0x37, 0x7E} + case 7: + return []byte{0x1B, 0x5B, 0x31, 0x38, 0x7E} + case 8: + return []byte{0x1B, 0x5B, 0x31, 0x39, 0x7E} + case 9: + return []byte{0x1B, 0x5B, 0x32, 0x30, 0x7E} + case 10: + return []byte{0x1B, 0x5B, 0x32, 0x31, 0x7E} + case 11: + return []byte{0x1B, 0x5B, 0x32, 0x32, 0x7E} + case 12: + return []byte{0x1B, 0x5B, 0x32, 0x34, 0x7E} + default: + return []byte{} + } +} + +func isTimeoutError(err error) bool { + if err == nil { + return false + } + if opErr, ok := err.(*os.PathError); ok { + err = opErr.Err + } + if syscallErr, ok := err.(syscall.Errno); ok { + return syscallErr == syscall.EAGAIN || syscallErr == syscall.EWOULDBLOCK || syscallErr == syscall.ETIMEDOUT + } + return strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "would block") +} + +func sanitizePreview(s string) string { + var buf bytes.Buffer + for _, r := range s { + if r == '\n' { + buf.WriteString("\\n") + } else if r == '\r' { + buf.WriteString("\\r") + } else if r == '\t' { + buf.WriteString("\\t") + } else if r >= 32 && r <= 126 { + buf.WriteRune(r) + } else if r == 0x1B { + buf.WriteString("^[") + } else if r < 32 { + fmt.Fprintf(&buf, "^%c", r+'A'-1) + } else { + buf.WriteRune(r) + } + } + return buf.String() +} diff --git a/internal/plugins/agentcli/plugin_test.go b/internal/plugins/agentcli/plugin_test.go new file mode 100644 index 0000000..ced6557 --- /dev/null +++ b/internal/plugins/agentcli/plugin_test.go @@ -0,0 +1,385 @@ +package agentcli + +import ( + "encoding/json" + "testing" + "time" + + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +type toolCapture struct { + handlers map[string]sdk.ToolHandler + defs map[string]sdk.ToolDef +} + +func newToolCapture() *toolCapture { + return &toolCapture{ + handlers: make(map[string]sdk.ToolHandler), + defs: make(map[string]sdk.ToolDef), + } +} + +func (tc *toolCapture) RegisterTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { + tc.handlers[name] = handler + tc.defs[name] = def + return nil +} +func (tc *toolCapture) RegisterStage(stage sdk.Stage, handler sdk.StageHandler) {} +func (tc *toolCapture) RegisterAPI(name string) error { return nil } + +func setupPlugin() (*Plugin, *toolCapture, error) { + p := New("agentcli") + tc := newToolCapture() + // Use nil for fields we don't need (iom, eventBus, etc.) + sdk := sdk.New("agentcli", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI) + if err := p.Start(sdk); err != nil { + return nil, nil, err + } + return p, tc, nil +} + +func TestKeyMapping(t *testing.T) { + tests := []struct { + key string + expected []byte + }{ + {"enter", []byte{0x0D}}, + {"tab", []byte{0x09}}, + {"escape", []byte{0x1B}}, + {"esc", []byte{0x1B}}, + {"backspace", []byte{0x7F}}, + {"delete", []byte{0x1B, 0x5B, 0x33, 0x7E}}, + {"home", []byte{0x1B, 0x5B, 0x48}}, + {"end", []byte{0x1B, 0x5B, 0x46}}, + {"up", []byte{0x1B, 0x5B, 0x41}}, + {"down", []byte{0x1B, 0x5B, 0x42}}, + {"left", []byte{0x1B, 0x5B, 0x44}}, + {"right", []byte{0x1B, 0x5B, 0x43}}, + {"page_up", []byte{0x1B, 0x5B, 0x35, 0x7E}}, + {"page_down", []byte{0x1B, 0x5B, 0x36, 0x7E}}, + {"ctrl_a", []byte{0x01}}, + {"ctrl_z", []byte{0x1A}}, + {"alt_a", []byte{0x1B, 'a'}}, + {"alt_z", []byte{0x1B, 'z'}}, + {"f1", []byte{0x1B, 0x5B, 0x50}}, + {"f4", []byte{0x1B, 0x5B, 0x53}}, + {"f12", []byte{0x1B, 0x5B, 0x32, 0x34, 0x7E}}, + } + + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + result, err := mapKey(tt.key) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytesEqual(result, tt.expected) { + t.Fatalf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestKeyMappingInvalid(t *testing.T) { + _, err := mapKey("unknown_key") + if err == nil { + t.Fatal("expected error for unknown key") + } +} + +func bytesEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestListEmpty(t *testing.T) { + p, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + defer p.Stop() + + handler := tc.handlers["terminal_list"] + result, err := handler(map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["status"] != "ok" { + t.Fatalf("expected status ok, got %v", resp["status"]) + } + if resp["count"].(float64) != 0 { + t.Fatalf("expected count 0, got %v", resp["count"]) + } +} + +func TestCreateAndCloseTerminal(t *testing.T) { + p, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + defer p.Stop() + + createHandler := tc.handlers["terminal_create"] + result, err := createHandler(map[string]interface{}{ + "command": "echo hello", + "timeout": "10s", + }) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var createResp map[string]interface{} + json.Unmarshal(data, &createResp) + + if v, ok := createResp["error"]; ok { + t.Skipf("PTY not available in this environment: %v", v) + } + if createResp["status"] != "created" { + t.Fatalf("expected status created, got %v", createResp["status"]) + } + + id := createResp["id"].(string) + if id == "" { + t.Fatal("expected non-empty terminal id") + } + + // Give the terminal a moment to output + time.Sleep(200 * time.Millisecond) + + // Read output + readHandler := tc.handlers["terminal_read"] + result, err = readHandler(map[string]interface{}{ + "id": id, + "clear": true, + }) + if err != nil { + t.Fatal(err) + } + + data, _ = json.Marshal(result) + var readResp map[string]interface{} + json.Unmarshal(data, &readResp) + if readResp["status"] != "ok" { + t.Fatalf("expected status ok, got %v", readResp["status"]) + } + + // Close + closeHandler := tc.handlers["terminal_close"] + result, err = closeHandler(map[string]interface{}{ + "id": id, + }) + if err != nil { + t.Fatal(err) + } + + data, _ = json.Marshal(result) + var closeResp map[string]interface{} + json.Unmarshal(data, &closeResp) + if closeResp["status"] != "closed" { + t.Fatalf("expected status closed, got %v", closeResp["status"]) + } +} + +func TestCreateTerminalMissingArgs(t *testing.T) { + p, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + defer p.Stop() + + handler := tc.handlers["terminal_create"] + result, err := handler(map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if v, ok := resp["error"]; ok { + t.Skipf("PTY not available in this environment: %v", v) + } + if resp["status"] != "created" { + t.Fatalf("expected status created, got %v", resp["status"]) + } + id := resp["id"].(string) + p.handleClose(map[string]interface{}{"id": id}) +} + +func TestWriteToNonexistentTerminal(t *testing.T) { + _, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + + handler := tc.handlers["terminal_write"] + result, err := handler(map[string]interface{}{ + "id": "nonexistent", + "input": "test", + }) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if _, ok := resp["error"]; !ok { + t.Fatal("expected error for nonexistent terminal") + } +} + +func TestReadNonexistentTerminal(t *testing.T) { + _, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + + handler := tc.handlers["terminal_read"] + result, err := handler(map[string]interface{}{ + "id": "nonexistent", + }) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if _, ok := resp["error"]; !ok { + t.Fatal("expected error for nonexistent terminal") + } +} + +func TestResizeNonexistentTerminal(t *testing.T) { + _, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + + handler := tc.handlers["terminal_resize"] + result, err := handler(map[string]interface{}{ + "id": "nonexistent", + "rows": float64(40), + "cols": float64(120), + }) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if _, ok := resp["error"]; !ok { + t.Fatal("expected error for nonexistent terminal") + } +} + +func TestCloseNonexistentTerminal(t *testing.T) { + _, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + + handler := tc.handlers["terminal_close"] + result, err := handler(map[string]interface{}{ + "id": "nonexistent", + }) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if _, ok := resp["error"]; !ok { + t.Fatal("expected error for nonexistent terminal") + } +} + +func TestTerminalWriteRequiresId(t *testing.T) { + _, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + + handler := tc.handlers["terminal_write"] + result, err := handler(map[string]interface{}{ + "input": "test", + }) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if _, ok := resp["error"]; !ok { + t.Fatal("expected error when id is missing") + } +} + +func TestTerminalWriteRequiresContent(t *testing.T) { + _, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + + handler := tc.handlers["terminal_write"] + result, err := handler(map[string]interface{}{ + "id": "test", + }) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if _, ok := resp["error"]; !ok { + t.Fatal("expected error when both input and key are missing") + } +} + +func TestToolsRegistered(t *testing.T) { + p, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + defer p.Stop() + + expectedTools := []string{ + "terminal_create", + "terminal_write", + "terminal_read", + "terminal_resize", + "terminal_close", + "terminal_list", + } + + for _, name := range expectedTools { + if _, ok := tc.handlers[name]; !ok { + t.Errorf("tool %s not registered", name) + } + } +} diff --git a/internal/plugins/all.go b/internal/plugins/all.go index 1e50708..92b8b47 100644 --- a/internal/plugins/all.go +++ b/internal/plugins/all.go @@ -1,7 +1,10 @@ package plugins import ( + _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/agentcli" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli" + _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cmd" + _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/mcp" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/timer" diff --git a/internal/plugins/cmd/plugin.go b/internal/plugins/cmd/plugin.go new file mode 100644 index 0000000..cc35a4f --- /dev/null +++ b/internal/plugins/cmd/plugin.go @@ -0,0 +1,115 @@ +package cmd + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +func init() { + plugin.RegisterFactory("cmd", func(name string, config map[string]interface{}) (sdk.Plugin, error) { + return New(name), nil + }) +} + +type Plugin struct { + name string +} + +func New(name string) *Plugin { + return &Plugin{name: name} +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + s.RegisterTool("cmd_run", sdk.ToolDef{ + Name: "cmd_run", + Description: "执行一条系统命令并返回输出。适用于查询系统信息、运行脚本、操作文件等单次命令场景。命令在临时 shell 中执行,不支持交互。如需交互式终端(如 vim、ssh、top),请使用 terminal_create 相关工具。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "command": map[string]interface{}{ + "type": "string", + "description": "要执行的命令", + }, + "timeout": map[string]interface{}{ + "type": "string", + "description": "超时时间,例如 10s, 1m, 30s(默认 30s)", + }, + "workdir": map[string]interface{}{ + "type": "string", + "description": "工作目录(可选,默认当前目录)", + }, + }, + "required": []string{"command"}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + command, _ := args["command"].(string) + if command == "" { + return map[string]interface{}{"error": "command is required"}, nil + } + + timeoutStr, _ := args["timeout"].(string) + if timeoutStr == "" { + timeoutStr = "30s" + } + timeout, err := time.ParseDuration(timeoutStr) + if err != nil { + return map[string]interface{}{"error": fmt.Sprintf("invalid timeout %q: %v", timeoutStr, err)}, nil + } + + workdir, _ := args["workdir"].(string) + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "sh", "-c", command) + if workdir != "" { + cmd.Dir = workdir + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if ctx.Err() != nil { + return map[string]interface{}{ + "status": "timeout", + "stdout": truncateOutput(stdout.String()), + "stderr": truncateOutput(stderr.String()), + "error": fmt.Sprintf("命令执行超时(%s)", timeoutStr), + }, nil + } + } + + return map[string]interface{}{ + "status": "ok", + "stdout": truncateOutput(stdout.String()), + "stderr": truncateOutput(stderr.String()), + "exit_code": cmd.ProcessState.ExitCode(), + "command": command, + }, nil + }) + + return nil +} + +func (p *Plugin) Stop() error { + return nil +} + +func truncateOutput(s string) string { + const maxLen = 32000 + if len(s) > maxLen { + return s[:maxLen] + fmt.Sprintf("\n... [输出被截断,共 %d 字节]", len(s)) + } + return strings.TrimRight(s, "\n") +} diff --git a/internal/plugins/cmd/plugin_test.go b/internal/plugins/cmd/plugin_test.go new file mode 100644 index 0000000..4a57dfe --- /dev/null +++ b/internal/plugins/cmd/plugin_test.go @@ -0,0 +1,261 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +type toolCapture struct { + handlers map[string]sdk.ToolHandler + defs map[string]sdk.ToolDef +} + +func newToolCapture() *toolCapture { + return &toolCapture{ + handlers: make(map[string]sdk.ToolHandler), + defs: make(map[string]sdk.ToolDef), + } +} + +func (tc *toolCapture) RegisterTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { + tc.handlers[name] = handler + tc.defs[name] = def + return nil +} + +func (tc *toolCapture) RegisterStage(stage sdk.Stage, handler sdk.StageHandler) {} + +func (tc *toolCapture) RegisterAPI(name string) error { return nil } + +func setupPlugin() (*Plugin, *toolCapture, error) { + p := New("cmd") + tc := newToolCapture() + sdk := sdk.New("cmd", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI) + if err := p.Start(sdk); err != nil { + return nil, nil, err + } + return p, tc, nil +} + +func TestCmdRunEcho(t *testing.T) { + _, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + + handler, ok := tc.handlers["cmd_run"] + if !ok { + t.Fatal("cmd_run tool not registered") + } + + result, err := handler(map[string]interface{}{ + "command": "echo hello world", + }) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["status"] != "ok" { + t.Fatalf("expected status ok, got %v", resp["status"]) + } + if resp["stdout"] != "hello world" { + t.Fatalf("expected 'hello world', got %v", resp["stdout"]) + } + if resp["exit_code"].(float64) != 0 { + t.Fatalf("expected exit code 0, got %v", resp["exit_code"]) + } +} + +func TestCmdRunWithStderr(t *testing.T) { + _, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + + handler := tc.handlers["cmd_run"] + result, err := handler(map[string]interface{}{ + "command": "echo out && echo err >&2 && exit 1", + }) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["status"] != "ok" { + t.Fatalf("expected status ok, got %v", resp["status"]) + } + if resp["stdout"] != "out" { + t.Fatalf("expected stdout 'out', got %v", resp["stdout"]) + } + if resp["stderr"] != "err" { + t.Fatalf("expected stderr 'err', got %v", resp["stderr"]) + } + if resp["exit_code"].(float64) != 1 { + t.Fatalf("expected exit code 1, got %v", resp["exit_code"]) + } +} + +func TestCmdRunTimeout(t *testing.T) { + _, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + + handler := tc.handlers["cmd_run"] + result, err := handler(map[string]interface{}{ + "command": "sleep 10", + "timeout": "1s", + }) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["status"] != "timeout" { + t.Fatalf("expected status timeout, got %v", resp["status"]) + } +} + +func TestCmdRunWorkdir(t *testing.T) { + _, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + + tmpDir, err := os.MkdirTemp("", "cmd_test_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + marker := filepath.Join(tmpDir, "marker.txt") + if err := os.WriteFile(marker, []byte("ok"), 0644); err != nil { + t.Fatal(err) + } + + handler := tc.handlers["cmd_run"] + result, err := handler(map[string]interface{}{ + "command": "cat marker.txt", + "workdir": tmpDir, + }) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["status"] != "ok" { + t.Fatalf("expected status ok, got %v", resp["status"]) + } + if resp["stdout"] != "ok" { + t.Fatalf("expected 'ok', got %v", resp["stdout"]) + } +} + +func TestCmdRunMissingCommand(t *testing.T) { + _, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + + handler := tc.handlers["cmd_run"] + result, err := handler(map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if _, ok := resp["error"]; !ok { + t.Fatal("expected error for missing command") + } +} + +func TestCmdRunNonZeroExit(t *testing.T) { + _, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + + handler := tc.handlers["cmd_run"] + result, err := handler(map[string]interface{}{ + "command": "exit 42", + }) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["status"] != "ok" { + t.Fatalf("expected status ok, got %v", resp["status"]) + } + if resp["exit_code"].(float64) != 42 { + t.Fatalf("expected exit code 42, got %v", resp["exit_code"]) + } +} + +func TestTruncateOutput(t *testing.T) { + short := "hello" + if s := truncateOutput(short); s != short { + t.Fatalf("expected %q, got %q", short, s) + } + + long := make([]byte, 40000) + for i := range long { + long[i] = 'x' + } + s := truncateOutput(string(long)) + if len(s) >= 40000 { + t.Fatal("expected truncation") + } + if len(s) > 32100 { + t.Fatalf("truncated string too long: %d", len(s)) + } +} + +func TestCmdRunPipeFail(t *testing.T) { + _, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + + handler := tc.handlers["cmd_run"] + result, err := handler(map[string]interface{}{ + "command": "false", + }) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["status"] != "ok" { + t.Fatalf("expected status ok, got %v", resp["status"]) + } + if resp["exit_code"].(float64) != 1 { + t.Fatalf("expected exit code 1, got %v", resp["exit_code"]) + } +} diff --git a/internal/plugins/healthcheck/plugin.go b/internal/plugins/healthcheck/plugin.go new file mode 100644 index 0000000..8746b2c --- /dev/null +++ b/internal/plugins/healthcheck/plugin.go @@ -0,0 +1,678 @@ +package healthcheck + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + "sync" + "time" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +var ( + hcStageHost *agentCore.StageHost + hcIOMgr *agentIO.IOManager + hcPluginReg *plugin.Registry + hcMemory *memory.GraphDB + hcKnowledge *knowledge.Store + hcDocStore *doc.Store + hcProviderMgr *agentAPI.ProviderManager + hcStatusProvider agentCore.StatusProvider +) + +type toolInfo struct { + Name string `json:"name"` + Source string `json:"source"` + Description string `json:"description"` +} + +type checkResult struct { + Name string `json:"name"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` + Pass bool `json:"pass"` +} + +// llmReport 由 LLM 通过 healthcheck_report 工具提交。 +type llmReport struct { + ToolName string `json:"tool_name"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` +} + +func Configure(sh *agentCore.StageHost, iom *agentIO.IOManager, pr *plugin.Registry, + mem *memory.GraphDB, ks *knowledge.Store, ds *doc.Store, pm *agentAPI.ProviderManager, sp agentCore.StatusProvider) { + hcStageHost = sh + hcIOMgr = iom + hcPluginReg = pr + hcMemory = mem + hcKnowledge = ks + hcDocStore = ds + hcProviderMgr = pm + hcStatusProvider = sp +} + +func init() { + plugin.RegisterFactory("healthcheck", func(name string, config map[string]interface{}) (sdk.Plugin, error) { + if hcStageHost == nil { + return nil, nil + } + return New(name), nil + }) +} + +type Plugin struct { + name string + mu sync.Mutex + reports []llmReport + sessionID string + selfToolNames map[string]bool + + stopCh chan struct{} + stopOnce sync.Once + perfData PerfData +} + +type PerfData struct { + LastCheck time.Time `json:"last_check"` + Checks []PerfCheckPoint `json:"checks"` +} +type PerfCheckPoint struct { + Time time.Time `json:"time"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Total int `json:"total"` + ElapsedMs int64 `json:"elapsed_ms"` +} + +func New(name string) *Plugin { + return &Plugin{ + name: name, + selfToolNames: make(map[string]bool), + stopCh: make(chan struct{}), + } +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + p.selfToolNames["healthcheck"] = true + s.RegisterTool("healthcheck", sdk.ToolDef{ + Name: "healthcheck", + Description: "运行系统全面健康检查。先执行静态检查(插件/工具列表/记忆/知识库/文档),再启动 LLM 驱动检查:LLM 主动发现并逐个测试各插件提供的工具,并通过 healthcheck_report 上报结果。返回详细的状态报告。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + return p.runFullCheck(s) + }) + + p.selfToolNames["healthcheck_plugins"] = true + s.RegisterTool("healthcheck_plugins", sdk.ToolDef{ + Name: "healthcheck_plugins", + Description: "列出所有已加载的插件及其状态。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + return p.checkPlugins(s) + }) + + p.selfToolNames["healthcheck_tools"] = true + s.RegisterTool("healthcheck_tools", sdk.ToolDef{ + Name: "healthcheck_tools", + Description: "列出系统中所有已注册的工具及其来源。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + return p.listAllTools() + }) + + p.selfToolNames["healthcheck_memory"] = true + s.RegisterTool("healthcheck_memory", sdk.ToolDef{ + Name: "healthcheck_memory", + Description: "测试图记忆系统:写入、查询、清理一条测试实体。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + return p.checkMemory() + }) + + p.selfToolNames["healthcheck_report"] = true + s.RegisterTool("healthcheck_report", sdk.ToolDef{ + Name: "healthcheck_report", + Description: "LLM 健康检查结果上报工具。LLM 在逐一测试各工具后,通过此工具提交每个工具的测试状态。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "tool_name": map[string]interface{}{"type": "string", "description": "被测试的工具名称"}, + "status": map[string]interface{}{"type": "string", "description": "测试结果:ok / fail / skip", "enum": []string{"ok", "fail", "skip"}}, + "detail": map[string]interface{}{"type": "string", "description": "测试详情或错误描述"}, + }, + "required": []string{"tool_name", "status"}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + toolName, _ := args["tool_name"].(string) + status, _ := args["status"].(string) + detail, _ := args["detail"].(string) + p.mu.Lock() + p.reports = append(p.reports, llmReport{ToolName: toolName, Status: status, Detail: detail}) + count := len(p.reports) + p.mu.Unlock() + log.Printf("[healthcheck] LLM report: tool=%s status=%s (total %d)", toolName, status, count) + return map[string]interface{}{"ok": true, "received": count}, nil + }) + + if hcStatusProvider != nil { + p.selfToolNames["healthcheck_kernel"] = true + s.RegisterTool("healthcheck_kernel", sdk.ToolDef{ + Name: "healthcheck_kernel", + Description: "查询 Agent 内核运行状态快照,包括插件/工具/记忆/知识库/LLM Provider/运行时等各子系统信息。Agent 可通过此工具自主监测内核健康。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + return hcStatusProvider.GetKernelStatus(), nil + }) + } + + p.selfToolNames["healthcheck_perf"] = true + s.RegisterTool("healthcheck_perf", sdk.ToolDef{ + Name: "healthcheck_perf", + Description: "查询健康检查性能监控数据,包括最近检查时间、历史检查记录(最多 100 条)及通过/失败统计。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + p.mu.Lock() + defer p.mu.Unlock() + passed := 0 + failed := 0 + for _, c := range p.perfData.Checks { + passed += c.Passed + failed += c.Failed + } + return map[string]interface{}{ + "status": "ok", + "last_check": p.perfData.LastCheck, + "total_checks": len(p.perfData.Checks), + "total_passed": passed, + "total_failed": failed, + "history": p.perfData.Checks, + }, nil + }) + + p.startAutoCheck(s, 30*time.Minute) + + log.Printf("[healthcheck] ready (stageHost=%v iom=%v reg=%v mem=%v ks=%v ds=%v pm=%v sp=%v)", + hcStageHost != nil, hcIOMgr != nil, hcPluginReg != nil, + hcMemory != nil, hcKnowledge != nil, hcDocStore != nil, hcProviderMgr != nil, hcStatusProvider != nil) + return nil +} + +func (p *Plugin) Stop() error { + p.stopOnce.Do(func() { + close(p.stopCh) + }) + return nil +} + +func (p *Plugin) startAutoCheck(s *sdk.PluginSDK, interval time.Duration) { + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + p.runAutoCheck(s) + } + } + }() + log.Printf("[healthcheck] auto-check started, interval=%v", interval) +} + +func (p *Plugin) runAutoCheck(s *sdk.PluginSDK) { + result, err := p.runFullCheck(s) + if err != nil { + log.Printf("[healthcheck] auto-check error: %v", err) + return + } + resp, _ := result.(map[string]interface{}) + passed, _ := resp["passed"].(int) + failed, _ := resp["failed"].(int) + total, _ := resp["total"].(int) + elapsed := int64(0) + if checks, ok := resp["checks"]; ok { + if arr, ok := checks.([]checkResult); ok && len(arr) > 0 { + elapsed = int64(len(arr)) // approximate + } + } + + pt := PerfCheckPoint{ + Time: time.Now(), + Passed: passed, + Failed: failed, + Total: total, + ElapsedMs: elapsed, + } + p.mu.Lock() + p.perfData.LastCheck = pt.Time + p.perfData.Checks = append(p.perfData.Checks, pt) + if len(p.perfData.Checks) > 100 { + p.perfData.Checks = p.perfData.Checks[len(p.perfData.Checks)-100:] + } + p.mu.Unlock() + + log.Printf("[healthcheck] auto-check complete: passed=%d failed=%d total=%d", passed, failed, total) +} + +func (p *Plugin) runFullCheck(s *sdk.PluginSDK) (interface{}, error) { + results := []checkResult{} + + pluginResult := p.checkPluginsRaw() + results = append(results, pluginResult...) + + toolResult := p.checkToolsRaw() + results = append(results, toolResult...) + + if hcMemory != nil { + r := p.testMemoryRaw() + results = append(results, r) + } else { + results = append(results, checkResult{Name: "memory", Status: "skip", Detail: "图记忆未初始化", Pass: true}) + } + + if hcKnowledge != nil { + r := p.testKnowledgeRaw() + results = append(results, r) + } else { + results = append(results, checkResult{Name: "knowledge", Status: "skip", Detail: "知识库未初始化", Pass: true}) + } + + if hcDocStore != nil { + r := p.testDocStoreRaw() + results = append(results, r) + } else { + results = append(results, checkResult{Name: "documents", Status: "skip", Detail: "文档记忆未初始化", Pass: true}) + } + + if hcProviderMgr != nil { + r := p.testLLMDriven() + results = append(results, r) + } else { + results = append(results, checkResult{Name: "llm_discovery", Status: "skip", Detail: "LLM Provider 未初始化", Pass: true}) + } + + passCount := 0 + failCount := 0 + for _, r := range results { + if r.Pass { + passCount++ + } else { + failCount++ + } + } + + summary := fmt.Sprintf("通过 %d / %d, 失败 %d", passCount, len(results), failCount) + + return map[string]interface{}{ + "status": "ok", + "summary": summary, + "total": len(results), + "passed": passCount, + "failed": failCount, + "checks": results, + }, nil +} + +func (p *Plugin) checkPlugins(s *sdk.PluginSDK) (interface{}, error) { + results := p.checkPluginsRaw() + return map[string]interface{}{ + "status": "ok", + "plugins": results, + "count": len(results), + }, nil +} + +func (p *Plugin) checkPluginsRaw() []checkResult { + if hcPluginReg == nil { + return []checkResult{{Name: "plugins", Status: "skip", Detail: "插件注册表未初始化", Pass: true}} + } + + names := hcPluginReg.List() + if names == nil { + names = []string{} + } + return []checkResult{{ + Name: "plugins", + Status: "ok", + Detail: fmt.Sprintf("已加载 %d 个插件: %v", len(names), names), + Pass: true, + }} +} + +func (p *Plugin) listAllTools() (interface{}, error) { + tools := p.collectAllTools() + return map[string]interface{}{ + "status": "ok", + "count": len(tools), + "tools": tools, + }, nil +} + +func (p *Plugin) checkToolsRaw() []checkResult { + tools := p.collectAllTools() + return []checkResult{{ + Name: "tools", + Status: "ok", + Detail: fmt.Sprintf("系统中共 %d 个工具", len(tools)), + Pass: true, + }} +} + +func (p *Plugin) collectAllTools() []toolInfo { + seen := map[string]bool{} + var tools []toolInfo + + addTool := func(name, source, desc string) { + if seen[name] { + return + } + seen[name] = true + tools = append(tools, toolInfo{Name: name, Source: source, Description: desc}) + } + + if hcStageHost != nil { + for _, def := range hcStageHost.GetToolDefs() { + addTool(def.Name, "plugin", def.Description) + } + } + + if hcIOMgr != nil { + for _, def := range hcIOMgr.GetAllTools() { + addTool(def.Name, "device", def.Description) + } + } + + return tools +} + +func (p *Plugin) testMemoryRaw() checkResult { + marker := fmt.Sprintf("_hc_%d", time.Now().UnixNano()) + triples := []memory.Triple{ + {Subject: marker, Relation: "is", Object: "healthcheck_test", SubjectType: "System", ObjectType: "Flag"}, + } + + start := time.Now() + ec, rc, err := hcMemory.Commit(triples, "healthcheck", 0) + if err != nil { + return checkResult{Name: "memory_write", Status: "fail", Detail: fmt.Sprintf("写入失败: %v", err), Pass: false} + } + + if _, _, err := hcMemory.Commit(triples, "healthcheck_cleanup", 0); err != nil { + log.Printf("[healthcheck] memory cleanup error: %v", err) + } + + n, err := hcMemory.Purge(map[string]string{"subject_contains": marker}, "hard") + if err != nil { + return checkResult{Name: "memory_purge", Status: "fail", Detail: fmt.Sprintf("清理失败: %v", err), Pass: false} + } + + elapsed := time.Since(start) + return checkResult{ + Name: "memory", + Status: "ok", + Detail: fmt.Sprintf("写入 %d 实体/%d 关系, 清理 %d 条, 耗时 %v", ec, rc, n, elapsed.Round(time.Millisecond)), + Pass: true, + } +} + +func (p *Plugin) testKnowledgeRaw() checkResult { + marker := fmt.Sprintf("_hc_knowledge_test_%d", time.Now().UnixNano()) + start := time.Now() + + if err := hcKnowledge.Add(marker, "健康检查测试标记,可忽略"); err != nil { + return checkResult{Name: "knowledge", Status: "fail", Detail: fmt.Sprintf("写入失败: %v", err), Pass: false} + } + + results := hcKnowledge.Search("健康检查测试标记", 3) + elapsed := time.Since(start) + + if len(results) > 0 { + return checkResult{ + Name: "knowledge", + Status: "ok", + Detail: fmt.Sprintf("写入+查询正常, 耗时 %v", elapsed.Round(time.Millisecond)), + Pass: true, + } + } + + return checkResult{ + Name: "knowledge", + Status: "warn", + Detail: fmt.Sprintf("写入成功但查询缓存未命中, 耗时 %v", elapsed.Round(time.Millisecond)), + Pass: true, + } +} + +func (p *Plugin) testDocStoreRaw() checkResult { + start := time.Now() + doc := &doc.Doc{ + Summary: "健康检查测试文档", + Content: "这是一条由 healthcheck 插件创建的测试文档,用于验证文档记忆系统是否正常工作。", + Tags: []string{"healthcheck", "test"}, + Source: "healthcheck", + } + if err := hcDocStore.Insert(doc); err != nil { + return checkResult{Name: "documents", Status: "fail", Detail: fmt.Sprintf("写入失败: %v", err), Pass: false} + } + + if doc.ID != "" { + hcDocStore.Remove(doc.ID) + } + + elapsed := time.Since(start) + return checkResult{ + Name: "documents", + Status: "ok", + Detail: fmt.Sprintf("写入+删除正常, 耗时 %v", elapsed.Round(time.Millisecond)), + Pass: true, + } +} + +func (p *Plugin) testLLMDriven() checkResult { + provider := hcProviderMgr.Default() + if provider == nil { + return checkResult{Name: "llm_discovery", Status: "skip", Detail: "无可用 LLM Provider", Pass: true} + } + + start := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + // 收集所有工具定义(排除健康检查自身的工具以避免循环测试) + toolDefs := p.collectToolDefsForLLM() + + if len(toolDefs) == 0 { + return checkResult{Name: "llm_discovery", Status: "skip", Detail: "没有可测试的工具", Pass: true} + } + + // 重置会话 + p.mu.Lock() + p.reports = nil + p.sessionID = fmt.Sprintf("hc_llm_%d", time.Now().UnixNano()) + p.mu.Unlock() + + // 构建 prompt + prompt := p.buildDiscoveryPrompt(toolDefs) + + msgs := []agentAPI.Message{{Role: "user", Content: prompt}} + tools := convertToolDefs(toolDefs) + + llmName := provider.Name() + turnCount := 0 + toolCallCount := 0 + + for turn := 0; turn < 20; turn++ { + resp, err := provider.Chat(ctx, &agentAPI.CompletionRequest{ + Messages: msgs, + MaxTokens: 4096, + Tools: tools, + ToolChoice: "auto", + }) + if err != nil { + return checkResult{ + Name: "llm_discovery", + Status: "fail", + Detail: fmt.Sprintf("Provider %s 第 %d 轮调用失败: %v (耗时 %v)", + llmName, turn+1, err, time.Since(start).Round(time.Millisecond)), + Pass: false, + } + } + + turnCount++ + + if len(resp.ToolCalls) == 0 { + break + } + + msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: resp.ToolCalls}) + + for _, tc := range resp.ToolCalls { + toolCallCount++ + content := p.executeToolForLLM(tc) + msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: content}) + } + } + + p.mu.Lock() + reportCount := len(p.reports) + p.mu.Unlock() + + elapsed := time.Since(start).Round(time.Millisecond) + + detail := fmt.Sprintf("Provider %s, %d 轮对话, %d 次工具调用, %d 份报告, 耗时 %v", + llmName, turnCount, toolCallCount, reportCount, elapsed) + + return checkResult{ + Name: "llm_discovery", + Status: "ok", + Detail: detail, + Pass: true, + } +} + +// collectToolDefsForLLM 收集全部已注册的工具定义供 LLM 发现和测试。 +// 动态排除本插件自身注册的工具(通过 selfToolNames),避免 LLM 自我循环调用。 +func (p *Plugin) collectToolDefsForLLM() []sdk.ToolDef { + seen := map[string]bool{} + var defs []sdk.ToolDef + + addDef := func(d sdk.ToolDef) { + if p.selfToolNames[d.Name] || seen[d.Name] { + return + } + seen[d.Name] = true + defs = append(defs, d) + } + + if hcStageHost != nil { + for _, d := range hcStageHost.GetToolDefs() { + addDef(d) + } + } + if hcIOMgr != nil { + for _, d := range hcIOMgr.GetAllTools() { + addDef(sdk.ToolDef{Name: d.Name, Description: d.Description, Parameters: d.Parameters}) + } + } + + return defs +} + +// buildDiscoveryPrompt 为 LLM 构造工具探索 prompt。 +func (p *Plugin) buildDiscoveryPrompt(toolDefs []sdk.ToolDef) string { + var b strings.Builder + b.WriteString(fmt.Sprintf(`你是一名系统健康检查专家。以下是系统中各插件提供的 %d 个工具(已自动排除健康检查插件自身工具): + +你的任务是:逐一尝试调用这些工具,验证它们是否正常工作,并对于每个工具使用 healthcheck_report 工具上报测试结果。 + +对于每个工具: +1. 理解它的参数和功能 +2. 构造合适的测试参数调用它 +3. 根据返回结果判断是否正常 +4. 调用 healthcheck_report 工具上报(tool_name, status=ok/fail/skip, detail=详情) + +注意: +- 有些工具有副作用(如写入数据),请使用安全参数,测试后应清理 +- 尽可能覆盖所有工具 +- 每个工具只需测试一次 + +开始测试!`, len(toolDefs))) + return b.String() +} + +// executeToolForLLM 在 LLM 工具循环中执行工具调用。 +// healthcheck_report 通过 StageHost 路由到自身注册的 handler,负责收集 LLM 上报。 +func (p *Plugin) executeToolForLLM(tc agentAPI.ToolCall) string { + if hcStageHost != nil { + result, err := hcStageHost.ExecuteTool(tc.Name, tc.Arguments) + if err != nil { + return fmt.Sprintf("调用工具 %s 失败: %v", tc.Name, err) + } + data, _ := json.Marshal(result) + return string(data) + } + + return fmt.Sprintf("工具 %s 不可执行(StageHost 未初始化)", tc.Name) +} + +func convertToolDefs(defs []sdk.ToolDef) []interface{} { + tools := make([]interface{}, len(defs)) + for i, d := range defs { + tools[i] = map[string]interface{}{ + "type": "function", + "function": map[string]interface{}{ + "name": d.Name, + "description": d.Description, + "parameters": d.Parameters, + }, + } + } + return tools +} + +func (p *Plugin) checkMemory() (interface{}, error) { + if hcMemory == nil { + return map[string]interface{}{"status": "skip", "pass": true, "detail": "图记忆未初始化"}, nil + } + r := p.testMemoryRaw() + c := map[string]interface{}{ + "status": r.Status, + "pass": r.Pass, + } + if r.Detail != "" { + c["detail"] = r.Detail + } + return c, nil +} diff --git a/internal/plugins/healthcheck/plugin_test.go b/internal/plugins/healthcheck/plugin_test.go new file mode 100644 index 0000000..8653320 --- /dev/null +++ b/internal/plugins/healthcheck/plugin_test.go @@ -0,0 +1,350 @@ +package healthcheck + +import ( + "encoding/json" + "os" + "testing" + + agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +type toolCapture struct { + handlers map[string]sdk.ToolHandler + defs map[string]sdk.ToolDef +} + +func newToolCapture() *toolCapture { + return &toolCapture{ + handlers: make(map[string]sdk.ToolHandler), + defs: make(map[string]sdk.ToolDef), + } +} + +func (tc *toolCapture) RegisterTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { + tc.handlers[name] = handler + tc.defs[name] = def + return nil +} +func (tc *toolCapture) RegisterStage(stage sdk.Stage, handler sdk.StageHandler) {} +func (tc *toolCapture) RegisterAPI(name string) error { return nil } + +func setupPlugin() (*Plugin, *toolCapture, error) { + sh := agentCore.NewStageHost() + iom := agentIO.NewIOManager() + pr := plugin.NewRegistry() + + Configure(sh, iom, pr, nil, nil, nil, nil, nil) + p := New("healthcheck") + tc := newToolCapture() + sdk := sdk.New("healthcheck", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI) + if err := p.Start(sdk); err != nil { + return nil, nil, err + } + return p, tc, nil +} + +func TestToolsRegistered(t *testing.T) { + _, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + + expected := []string{ + "healthcheck", + "healthcheck_plugins", + "healthcheck_tools", + "healthcheck_memory", + "healthcheck_report", + "healthcheck_perf", + } + for _, name := range expected { + if _, ok := tc.handlers[name]; !ok { + t.Errorf("tool %q not registered", name) + } + } +} + +func TestHealthcheckFull(t *testing.T) { + _, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + + handler := tc.handlers["healthcheck"] + result, err := handler(map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["status"] != "ok" { + t.Fatalf("expected status ok, got %v", resp["status"]) + } + + checks := resp["checks"].([]interface{}) + if len(checks) == 0 { + t.Fatal("expected at least some checks") + } + + for _, c := range checks { + cr := c.(map[string]interface{}) + name := cr["name"].(string) + pass := cr["pass"].(bool) + if !pass && cr["status"] != "skip" { + t.Errorf("check %q failed: %v (detail: %v)", name, cr["status"], cr["detail"]) + } + } +} + +func TestHealthcheckPlugins(t *testing.T) { + _, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + + handler := tc.handlers["healthcheck_plugins"] + result, err := handler(map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["status"] != "ok" { + t.Fatalf("expected status ok, got %v", resp["status"]) + } +} + +func TestHealthcheckToolsList(t *testing.T) { + _, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + + handler := tc.handlers["healthcheck_tools"] + result, err := handler(map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["status"] != "ok" { + t.Fatalf("expected status ok, got %v", resp["status"]) + } +} + +func TestHealthcheckMemoryNotAvailable(t *testing.T) { + _, tc, err := setupPlugin() + if err != nil { + t.Fatal(err) + } + + handler := tc.handlers["healthcheck_memory"] + result, err := handler(map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + // Memory is nil in this setup, so it should skip gracefully + if _, ok := resp["pass"]; ok { + pass := resp["pass"].(bool) + if !pass { + t.Fatalf("expected pass=true when memory is nil, got false: %v", resp) + } + } +} + +func TestHealthcheckWithMemory(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "hc_test_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + memDB, err := memory.NewGraphDB(tmpDir + "/test.db") + if err != nil { + t.Fatal(err) + } + defer memDB.Close() + + sh := agentCore.NewStageHost() + iom := agentIO.NewIOManager() + pr := plugin.NewRegistry() + + Configure(sh, iom, pr, memDB, nil, nil, nil, nil) + p := New("healthcheck") + tc := newToolCapture() + sdk := sdk.New("healthcheck", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI) + if err := p.Start(sdk); err != nil { + t.Fatal(err) + } + + handler := tc.handlers["healthcheck_memory"] + result, err := handler(map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["status"] != "ok" { + t.Fatalf("expected status ok, got %v", resp["status"]) + } + if pass, ok := resp["pass"].(bool); !ok || !pass { + t.Fatalf("expected pass=true, got pass=%v status=%v detail=%v", pass, resp["status"], resp["detail"]) + } +} + +func TestHealthcheckWithKnowledge(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "hc_know_test_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + ks := knowledge.NewStore(tmpDir) + if err := ks.Start(); err != nil { + t.Fatal(err) + } + defer ks.Stop() + + sh := agentCore.NewStageHost() + iom := agentIO.NewIOManager() + pr := plugin.NewRegistry() + + Configure(sh, iom, pr, nil, ks, nil, nil, nil) + p := New("healthcheck") + tc := newToolCapture() + sdk := sdk.New("healthcheck", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI) + if err := p.Start(sdk); err != nil { + t.Fatal(err) + } + + handler := tc.handlers["healthcheck"] + result, err := handler(map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["status"] != "ok" { + t.Fatalf("expected status ok, got %v", resp["status"]) + } + + checks := resp["checks"].([]interface{}) + var knowledgeCheck map[string]interface{} + for _, c := range checks { + cr := c.(map[string]interface{}) + if cr["name"] == "knowledge" { + knowledgeCheck = cr + break + } + } + + if knowledgeCheck == nil { + t.Fatal("expected knowledge check in results") + } +} + +func TestHealthcheckWithDocStore(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "hc_doc_test_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + ds := doc.NewStore(tmpDir) + if err := ds.Start(); err != nil { + t.Fatal(err) + } + defer ds.Stop() + + sh := agentCore.NewStageHost() + iom := agentIO.NewIOManager() + pr := plugin.NewRegistry() + + Configure(sh, iom, pr, nil, nil, ds, nil, nil) + p := New("healthcheck") + tc := newToolCapture() + sdk := sdk.New("healthcheck", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI) + if err := p.Start(sdk); err != nil { + t.Fatal(err) + } + + handler := tc.handlers["healthcheck"] + result, err := handler(map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["status"] != "ok" { + t.Fatalf("expected status ok, got %v", resp["status"]) + } + + checks := resp["checks"].([]interface{}) + var docCheck map[string]interface{} + for _, c := range checks { + cr := c.(map[string]interface{}) + if cr["name"] == "documents" { + docCheck = cr + break + } + } + + if docCheck == nil { + t.Fatal("expected documents check in results") + } +} + +func TestLLMReportCollection(t *testing.T) { + p := &Plugin{name: "healthcheck"} + if len(p.reports) != 0 { + t.Fatal("expected empty reports") + } + p.mu.Lock() + p.reports = append(p.reports, llmReport{ToolName: "test_tool", Status: "ok", Detail: "test passed"}) + count := len(p.reports) + p.mu.Unlock() + if count != 1 { + t.Fatalf("expected 1 report, got %d", count) + } + if p.reports[0].ToolName != "test_tool" { + t.Fatalf("expected tool_name=test_tool, got %s", p.reports[0].ToolName) + } +} + +func TestConfigureNilStageHost(t *testing.T) { + Configure(nil, nil, nil, nil, nil, nil, nil, nil) + if hcStageHost != nil { + t.Fatal("expected hcStageHost to be nil") + } +} + + diff --git a/internal/plugins/integration_test.go b/internal/plugins/integration_test.go new file mode 100644 index 0000000..6043aff --- /dev/null +++ b/internal/plugins/integration_test.go @@ -0,0 +1,584 @@ +package plugins + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" + luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" + cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli" + healthcheck "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck" + openclaw "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw" + webui "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +type testPluginEnv struct { + tmpDir string + stageHost *agentCore.StageHost + iom *agentIO.IOManager + pluginReg *plugin.Registry + memDB *memory.GraphDB + ks *knowledge.Store + docStore *doc.Store +} + +func setupIntegration(t *testing.T) *testPluginEnv { + return setupIntegrationWithProvider(t, nil) +} + +func setupIntegrationWithProvider(t *testing.T, pm *agentAPI.ProviderManager) *testPluginEnv { + t.Helper() + + tmpDir, err := os.MkdirTemp("", "hc_integration_*") + if err != nil { + t.Fatal(err) + } + + stageHost := agentCore.NewStageHost() + iom := agentIO.NewIOManager() + pluginReg := plugin.NewRegistry() + + pluginReg.SetIOManager(iom) + pluginReg.SetMemory(nil) + + pluginReg.SetToolRegistrar(func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { + return stageHost.RegisterTool(name, def, handler) + }) + pluginReg.SetStageRegistrar(func(stage sdk.Stage, handler sdk.StageHandler) { + stageHost.RegisterStage(stage, handler) + }) + pluginReg.SetAPIRegistrar(func(name string) error { + return nil + }) + + memDB, err := memory.NewGraphDB(filepath.Join(tmpDir, "test.db")) + if err != nil { + t.Fatal(err) + } + pluginReg.SetMemory(memDB) + + ks := knowledge.NewStore(filepath.Join(tmpDir, "knowledge")) + if err := ks.Start(); err != nil { + t.Fatal(err) + } + + docStore := doc.NewStore(filepath.Join(tmpDir, "documents")) + if err := docStore.Start(); err != nil { + t.Fatal(err) + } + + cli.DefaultSocket = filepath.Join(tmpDir, "cli.sock") + openclaw.SkillsDir = filepath.Join(tmpDir, "skills") + os.MkdirAll(openclaw.SkillsDir, 0755) + webui.Configure(":0", nil, memDB, nil, nil, nil, iom, nil, ks, nil, nil, pluginReg, nil, nil) + healthcheck.Configure(stageHost, iom, pluginReg, memDB, ks, docStore, pm, nil) + + plgDir := filepath.Join(tmpDir, "plugins") + os.MkdirAll(plgDir, 0755) + if err := pluginReg.Load(plgDir); err != nil { + t.Fatal(err) + } + + return &testPluginEnv{ + tmpDir: tmpDir, + stageHost: stageHost, + iom: iom, + pluginReg: pluginReg, + memDB: memDB, + ks: ks, + docStore: docStore, + } +} + +func (e *testPluginEnv) cleanup() { + e.pluginReg.StopAll() + e.memDB.Close() + e.ks.Stop() + e.docStore.Stop() + os.RemoveAll(e.tmpDir) +} + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +func TestIntegrationAllPluginsRegister(t *testing.T) { + env := setupIntegration(t) + defer env.cleanup() + + expectedTools := []string{ + "cmd_run", + "terminal_create", "terminal_write", "terminal_read", + "terminal_resize", "terminal_close", "terminal_list", + "healthcheck", "healthcheck_plugins", "healthcheck_tools", "healthcheck_memory", "healthcheck_perf", + "timer_set", + } + + defs := env.stageHost.GetToolDefs() + registered := make(map[string]bool) + for _, def := range defs { + registered[def.Name] = true + } + + var missing []string + for _, tool := range expectedTools { + if !registered[tool] { + missing = append(missing, tool) + } + } + if len(missing) > 0 { + all := make([]string, 0, len(defs)) + for _, d := range defs { + all = append(all, d.Name) + } + t.Fatalf("missing tools: %v\nall registered: %v", missing, all) + } + t.Logf("all %d expected tools registered (total: %d)", len(expectedTools), len(defs)) +} + +// --------------------------------------------------------------------------- +// cmd_run +// --------------------------------------------------------------------------- + +func TestIntegrationCmdRun(t *testing.T) { + env := setupIntegration(t) + defer env.cleanup() + + result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{ + "command": "echo hello_world", + }) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["exit_code"] != 0.0 { + t.Fatalf("expected exit_code 0, got %v", resp["exit_code"]) + } + stdout, ok := resp["stdout"].(string) + if !ok || stdout != "hello_world" { + t.Fatalf("expected stdout 'hello_world', got %q", stdout) + } + t.Logf("cmd_run OK: exit_code=0 stdout=%q", stdout) +} + +func TestIntegrationCmdRunWithWorkdir(t *testing.T) { + env := setupIntegration(t) + defer env.cleanup() + + result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{ + "command": "pwd", + "workdir": "/tmp", + }) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["exit_code"] != 0.0 { + t.Fatalf("expected exit_code 0, got %v", resp["exit_code"]) + } + stdout := resp["stdout"].(string) + if stdout != "/tmp" { + t.Fatalf("expected stdout '/tmp', got %q", stdout) + } + t.Logf("cmd_run workdir OK: stdout=%q", stdout) +} + +func TestIntegrationCmdRunInvalidTimeout(t *testing.T) { + env := setupIntegration(t) + defer env.cleanup() + + result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{ + "command": "echo ok", + "timeout": "not-a-duration", + }) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + errMsg, ok := resp["error"].(string) + if !ok || errMsg == "" { + t.Fatalf("expected error for invalid timeout, got %v", resp) + } + t.Logf("cmd_run invalid_timeout OK: error=%q", errMsg) +} + +func TestIntegrationCmdRunStderr(t *testing.T) { + env := setupIntegration(t) + defer env.cleanup() + + result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{ + "command": "echo stderr_test >&2", + }) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["exit_code"] != 0.0 { + t.Fatalf("expected exit_code 0, got %v", resp["exit_code"]) + } + stderr := resp["stderr"].(string) + if stderr != "stderr_test" { + t.Fatalf("expected stderr 'stderr_test', got %q", stderr) + } + t.Logf("cmd_run stderr OK: stderr=%q", stderr) +} + +// --------------------------------------------------------------------------- +// Terminal (PTY) +// --------------------------------------------------------------------------- + +func TestIntegrationPtyCreateListClose(t *testing.T) { + env := setupIntegration(t) + defer env.cleanup() + + // Create + result, err := env.stageHost.ExecuteTool("terminal_create", map[string]interface{}{ + "shell": "/bin/sh", + "name": "hci_test_shell", + }) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + id, ok := resp["id"].(string) + if !ok || id == "" { + status := resp["status"] + detail := resp["detail"] + if status == "error" { + t.Skipf("PTY not available: %v", detail) + } + t.Fatalf("expected non-empty terminal id, got id=%q status=%v detail=%v", id, status, detail) + } + t.Logf("terminal_create OK: id=%s", id) + + // List + result2, err := env.stageHost.ExecuteTool("terminal_list", map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + data2, _ := json.Marshal(result2) + var resp2 map[string]interface{} + json.Unmarshal(data2, &resp2) + + terminals, ok := resp2["terminals"].([]interface{}) + if !ok { + t.Fatalf("expected terminals array, got %T", resp2["terminals"]) + } + if len(terminals) < 1 { + t.Fatal("expected at least 1 terminal") + } + t.Logf("terminal_list OK: %d terminals", len(terminals)) + + // Close + _, err = env.stageHost.ExecuteTool("terminal_close", map[string]interface{}{ + "id": id, + }) + if err != nil { + t.Fatal(err) + } + t.Logf("terminal_close OK: id=%s", id) +} + +func TestIntegrationPtyInteractive(t *testing.T) { + env := setupIntegration(t) + defer env.cleanup() + + createResult, err := env.stageHost.ExecuteTool("terminal_create", map[string]interface{}{ + "shell": "/bin/sh", + "name": "hci_interactive", + }) + if err != nil { + t.Fatal(err) + } + cdata, _ := json.Marshal(createResult) + var cresp map[string]interface{} + json.Unmarshal(cdata, &cresp) + + id, ok := cresp["id"].(string) + if !ok || id == "" { + status := cresp["status"] + if status == "error" { + t.Skipf("PTY not available: %v", cresp["detail"]) + } + t.Fatalf("expected terminal id, got %v", cresp) + } + + // Write a command + _, err = env.stageHost.ExecuteTool("terminal_write", map[string]interface{}{ + "id": id, + "input": "echo pty_works\n", + }) + if err != nil { + t.Fatal(err) + } + + time.Sleep(500 * time.Millisecond) + + // Read output + readResult, err := env.stageHost.ExecuteTool("terminal_read", map[string]interface{}{ + "id": id, + }) + if err != nil { + t.Fatal(err) + } + rdata, _ := json.Marshal(readResult) + var rresp map[string]interface{} + json.Unmarshal(rdata, &rresp) + + output, ok := rresp["output"].(string) + if !ok || output == "" { + t.Fatalf("expected output, got output=%q response=%v", output, rresp) + } + t.Logf("terminal_write+read OK: output=%q", output) + + // Close + env.stageHost.ExecuteTool("terminal_close", map[string]interface{}{ + "id": id, + }) +} + +func TestIntegrationPtyResize(t *testing.T) { + env := setupIntegration(t) + defer env.cleanup() + + createResult, err := env.stageHost.ExecuteTool("terminal_create", map[string]interface{}{ + "shell": "/bin/sh", + "name": "hci_resize_test", + }) + if err != nil { + t.Fatal(err) + } + cdata, _ := json.Marshal(createResult) + var cresp map[string]interface{} + json.Unmarshal(cdata, &cresp) + + id, ok := cresp["id"].(string) + if !ok || id == "" { + status := cresp["status"] + if status == "error" { + t.Skipf("PTY not available: %v", cresp["detail"]) + } + t.Fatalf("expected terminal id, got %v", cresp) + } + + _, err = env.stageHost.ExecuteTool("terminal_resize", map[string]interface{}{ + "id": id, + "rows": 80.0, + "cols": 200.0, + }) + if err != nil { + t.Fatal(err) + } + t.Logf("terminal_resize OK: id=%s rows=80 cols=200", id) + + env.stageHost.ExecuteTool("terminal_close", map[string]interface{}{ + "id": id, + }) +} + +// --------------------------------------------------------------------------- +// Healthcheck +// --------------------------------------------------------------------------- + +func TestIntegrationHealthcheck(t *testing.T) { + env := setupIntegration(t) + defer env.cleanup() + + result, err := env.stageHost.ExecuteTool("healthcheck", map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["status"] != "ok" { + t.Fatalf("expected status=ok, got %v", resp["status"]) + } + checks := resp["checks"].([]interface{}) + if len(checks) == 0 { + t.Fatal("expected non-empty checks array") + } + t.Logf("healthcheck OK: %d checks, status=%v", len(checks), resp["status"]) +} + +func TestIntegrationHealthcheckPlugins(t *testing.T) { + env := setupIntegration(t) + defer env.cleanup() + + result, err := env.stageHost.ExecuteTool("healthcheck_plugins", map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["status"] != "ok" { + t.Fatalf("expected status=ok, got %v", resp["status"]) + } + t.Logf("healthcheck_plugins OK: status=%v", resp["status"]) +} + +func TestIntegrationHealthcheckToolsList(t *testing.T) { + env := setupIntegration(t) + defer env.cleanup() + + result, err := env.stageHost.ExecuteTool("healthcheck_tools", map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["status"] != "ok" { + t.Fatalf("expected status=ok, got %v", resp["status"]) + } + t.Logf("healthcheck_tools OK: status=%v", resp["status"]) +} + +func TestIntegrationHealthcheckMemory(t *testing.T) { + env := setupIntegration(t) + defer env.cleanup() + + result, err := env.stageHost.ExecuteTool("healthcheck_memory", map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + if resp["status"] != "ok" { + t.Fatalf("expected status=ok, got %v", resp["status"]) + } + t.Logf("healthcheck_memory OK: status=%v", resp["status"]) +} + +func TestIntegrationToolNotFound(t *testing.T) { + env := setupIntegration(t) + defer env.cleanup() + + _, err := env.stageHost.ExecuteTool("nonexistent_tool_xyz", nil) + if err == nil { + t.Fatal("expected error for nonexistent tool") + } + t.Logf("tool_not_found OK: err=%v", err) +} + +func TestIntegrationLLMDrivenDiscoveryWithRealKey(t *testing.T) { + apiKey := os.Getenv("DEEPSEEK_API_KEY") + if apiKey == "" { + t.Skip("DEEPSEEK_API_KEY not set") + } + + // Lua VM + DeepSeek Provider + tmpVM := t.TempDir() + vm := luaVM.NewVM(tmpVM + "/adapters") + if err := vm.Start(); err != nil { + t.Fatal(err) + } + defer vm.Stop() + + pm := agentAPI.NewProviderManager() + pm.Register("deepseek", agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{ + Model: "deepseek-v4-flash", + BaseURL: "https://api.deepseek.com", + APIKey: apiKey, + }, vm, "deepseek")) + + // Setup — 加载所有真实内置插件 + env := setupIntegrationWithProvider(t, pm) + defer env.cleanup() + + // 列出已加载的真实工具 + defs := env.stageHost.GetToolDefs() + t.Logf("共有 %d 个已注册的真实工具:", len(defs)) + for _, d := range defs { + t.Logf(" - %s", d.Name) + } + + // 调用 healthcheck 进行全面检查(含 LLM 驱动阶段) + result, err := env.stageHost.ExecuteTool("healthcheck", map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(data, &resp) + + t.Logf("===== Full Healthcheck Result =====") + t.Logf("status: %v", resp["status"]) + t.Logf("summary: %v", resp["summary"]) + t.Logf("total: %v", resp["total"]) + t.Logf("passed: %v", resp["passed"]) + t.Logf("failed: %v", resp["failed"]) + + checks := resp["checks"].([]interface{}) + for _, c := range checks { + cr := c.(map[string]interface{}) + prefix := "✅" + if cr["status"] == "fail" { + prefix = "❌" + } + t.Logf(" %s %s: %s %s", prefix, cr["name"], cr["status"], cr["detail"]) + } + + if resp["status"] != "ok" { + t.Fatalf("expected status=ok, got %v", resp["status"]) + } + + // 验证 LLM 发现阶段的存在 + foundDiscovery := false + for _, c := range checks { + cr := c.(map[string]interface{}) + if cr["name"] == "llm_discovery" { + foundDiscovery = true + if cr["status"] != "ok" { + t.Fatalf("LLM discovery failed: status=%s detail=%s", cr["status"], cr["detail"]) + } + break + } + } + if !foundDiscovery { + t.Fatal("expected llm_discovery check in results") + } + + t.Logf("✅ LLM-driven tool discovery test PASSED") +} diff --git a/internal/plugins/openclaw/plugin.go b/internal/plugins/openclaw/plugin.go index 7922db8..2a99475 100644 --- a/internal/plugins/openclaw/plugin.go +++ b/internal/plugins/openclaw/plugin.go @@ -1,17 +1,22 @@ package openclaw import ( + _ "embed" "fmt" "log" "os" "path/filepath" + "sync" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" ) -// SkillsDir 由 main.go 在 Load() 前设置,指向 SKILL.md 存放目录。 +//go:embed simulator/main.js +var simulatorSrc string + var SkillsDir string +var SimulatorDir string func init() { plugin.RegisterFactory("openclaw", func(name string, config map[string]interface{}) (sdk.Plugin, error) { @@ -28,15 +33,23 @@ func init() { } type Plugin struct { - name string - skillsDir string - skills []*plugin.SKILLPlugin + name string + skillsDir string + simulatorDir string + skills []*plugin.SKILLPlugin + sidecars []*sidecarProcess + mu sync.Mutex } func New(name, skillsDir string) *Plugin { + sd := SimulatorDir + if sd == "" { + sd = filepath.Join(skillsDir, ".simulator") + } return &Plugin{ - name: name, - skillsDir: skillsDir, + name: name, + skillsDir: skillsDir, + simulatorDir: sd, } } @@ -53,40 +66,158 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { for _, entry := range entries { skillPath := filepath.Join(p.skillsDir, entry.Name()) - sk, err := plugin.LoadSKILL(skillPath) + subs, err := os.ReadDir(skillPath) if err != nil { - log.Printf("[openclaw] load skill %s: %v", entry.Name(), err) continue } - p.skills = append(p.skills, sk) - // Register each tool defined in the SKILL - for _, td := range sk.Tools() { - name := td.Name - def := sdk.ToolDef{ - Name: name, - Description: td.Description, - Parameters: td.Parameters, - } - // SKILL tools are informational (advisory) — no handler - if err := s.RegisterTool(name, def, nil); err != nil { - log.Printf("[openclaw] register tool %s: %v", name, err) + hasMainJS := false + hasOCManifest := false + for _, f := range subs { + switch f.Name() { + case "main.js": + hasMainJS = true + case "openclaw.plugin.json": + hasOCManifest = true } } - // Register IO config as a channel if defined - if iocfg := sk.IOConfig(); iocfg != nil { - log.Printf("[openclaw] skill %s io: type=%s in=%s out=%s caps=%v", - sk.Name(), iocfg.Type, iocfg.InputRoute, iocfg.OutputRoute, iocfg.OutputCaps) - } + switch { + case hasMainJS: + if err := p.loadSidecar(s, skillPath, entry.Name()); err != nil { + log.Printf("[openclaw] sidecar %s: %v", entry.Name(), err) + } + case hasOCManifest: + if err := p.loadOCPlugin(s, skillPath, entry.Name()); err != nil { + log.Printf("[openclaw] ocplugin %s: %v", entry.Name(), err) + } + default: + sk, err := plugin.LoadSKILL(skillPath) + if err != nil { + log.Printf("[openclaw] load skill %s: %v", entry.Name(), err) + continue + } + p.skills = append(p.skills, sk) - log.Printf("[openclaw] loaded skill: %s v%s", sk.Name(), sk.Version()) + for _, td := range sk.Tools() { + if err := s.RegisterTool(td.Name, sdk.ToolDef{ + Name: td.Name, + Description: td.Description, + Parameters: td.Parameters, + }, nil); err != nil { + log.Printf("[openclaw] register tool %s: %v", td.Name, err) + } + } + + if iocfg := sk.IOConfig(); iocfg != nil { + log.Printf("[openclaw] skill %s io: type=%s in=%s out=%s caps=%v", + sk.Name(), iocfg.Type, iocfg.InputRoute, iocfg.OutputRoute, iocfg.OutputCaps) + } + + log.Printf("[openclaw] loaded skill: %s v%s", sk.Name(), sk.Version()) + } } return nil } +func (p *Plugin) loadOCPlugin(s *sdk.PluginSDK, dir, name string) error { + simPath := filepath.Join(p.simulatorDir, "main.js") + if err := os.MkdirAll(p.simulatorDir, 0755); err != nil { + return fmt.Errorf("create simulator dir: %w", err) + } + if err := os.WriteFile(simPath, []byte(simulatorSrc), 0644); err != nil { + return fmt.Errorf("write simulator: %w", err) + } + + sp, err := launchProcess("node", simPath, dir, name) + if err != nil { + return fmt.Errorf("launch simulator: %w", err) + } + if sp == nil { + return nil + } + + tools, err := sp.ListTools() + if err != nil { + sp.Close() + return fmt.Errorf("list tools: %w", err) + } + + for _, tool := range tools { + toolName := fmt.Sprintf("%s_%s", name, tool.Name) + tDef := sdk.ToolDef{ + Name: toolName, + Description: fmt.Sprintf("[%s] %s", name, tool.Description), + Parameters: tool.InputSchema, + } + handler := func(sp *sidecarProcess, toolName string) sdk.ToolHandler { + return func(args map[string]interface{}) (interface{}, error) { + return sp.CallTool(toolName, args) + } + }(sp, tool.Name) + if err := s.RegisterTool(toolName, tDef, handler); err != nil { + log.Printf("[openclaw] register ocplugin tool %s: %v", toolName, err) + continue + } + log.Printf("[openclaw] registered ocplugin tool: %s (from %s)", toolName, name) + } + + p.mu.Lock() + p.sidecars = append(p.sidecars, sp) + p.mu.Unlock() + log.Printf("[openclaw] ocplugin %s started with %d tools", name, len(tools)) + return nil +} + +func (p *Plugin) loadSidecar(s *sdk.PluginSDK, dir, name string) error { + sp, err := launchSidecar(dir, name) + if err != nil { + return fmt.Errorf("launch: %w", err) + } + if sp == nil { + return nil + } + + tools, err := sp.ListTools() + if err != nil { + sp.Close() + return fmt.Errorf("list tools: %w", err) + } + + for _, tool := range tools { + toolName := fmt.Sprintf("%s_%s", name, tool.Name) + tDef := sdk.ToolDef{ + Name: toolName, + Description: fmt.Sprintf("[%s] %s", name, tool.Description), + Parameters: tool.InputSchema, + } + handler := func(sp *sidecarProcess, toolName string) sdk.ToolHandler { + return func(args map[string]interface{}) (interface{}, error) { + return sp.CallTool(toolName, args) + } + }(sp, tool.Name) + if err := s.RegisterTool(toolName, tDef, handler); err != nil { + log.Printf("[openclaw] register sidecar tool %s: %v", toolName, err) + continue + } + log.Printf("[openclaw] registered sidecar tool: %s (from %s)", toolName, name) + } + + p.mu.Lock() + p.sidecars = append(p.sidecars, sp) + p.mu.Unlock() + log.Printf("[openclaw] sidecar %s started with %d tools", name, len(tools)) + return nil +} + func (p *Plugin) Stop() error { + p.mu.Lock() + defer p.mu.Unlock() + for _, sp := range p.sidecars { + sp.Close() + } + p.sidecars = nil p.skills = nil return nil } diff --git a/internal/plugins/openclaw/sidecar.go b/internal/plugins/openclaw/sidecar.go new file mode 100644 index 0000000..d98d3aa --- /dev/null +++ b/internal/plugins/openclaw/sidecar.go @@ -0,0 +1,228 @@ +package openclaw + +import ( + "bufio" + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "sync" + "time" +) + +type sidecarRequest struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params interface{} `json:"params,omitempty"` +} + +type sidecarResponse struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Result *json.RawMessage `json:"result,omitempty"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +type OCPTool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]interface{} `json:"inputSchema"` +} + +type OCCallResult struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + } `json:"content"` +} + +type sidecarProcess struct { + name string + dir string + cmd *exec.Cmd + stdin *bufio.Writer + stdout *bufio.Scanner + mu sync.Mutex + nextID int + closed bool + stopped bool +} + +func launchSidecar(dir, name string) (*sidecarProcess, error) { + mainJS := filepath.Join(dir, "main.js") + if _, err := os.Stat(mainJS); os.IsNotExist(err) { + return nil, nil + } + return launchProcess("node", mainJS, dir, name) +} + +func launchProcess(bin, arg, dir, name string) (*sidecarProcess, error) { + nodePath := bin + if bin == "node" { + if p := os.Getenv("NODE_PATH"); p != "" { + nodePath = filepath.Join(p, "node") + } + } + + // 将 dir(插件目录)作为最后一个参数传给 Node.js 进程 + // 这样: node + + diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index 8d7d626..fb62a16 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -1,6 +1,7 @@ package webui import ( + "embed" "encoding/json" "fmt" "net/http" @@ -10,6 +11,7 @@ import ( "strings" "time" + agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core" agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" "gitcode.com/JianFeeeee/HomeAgent/internal/events" @@ -24,43 +26,57 @@ import ( "gitcode.com/JianFeeeee/HomeAgent/pkg/types" ) -type Handler struct { - supervisor *supervisor.Daemon - memory *memory.GraphDB - indexer *memory.Indexer - skills *skill.Manager - lua *luaVM.VM - config *types.Config - startTime time.Time - iom *agentIO.IOManager - textMem *text.Memory - knowledge *knowledge.Store - tracker *tracker.Tracker - cfgReg *internalConfig.ConfigRegistry - pluginReg *plugin.Registry - eventBus *events.Bus +//go:embed dashboard.html +var dashboardFS embed.FS + +var dashboardHTML string + +func init() { + data, err := dashboardFS.ReadFile("dashboard.html") + if err == nil { + dashboardHTML = string(data) + } } -func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker, cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus) *Handler { +type Handler struct { + supervisor *supervisor.Daemon + memory *memory.GraphDB + indexer *memory.Indexer + skills *skill.Manager + lua *luaVM.VM + config *types.Config + startTime time.Time + iom *agentIO.IOManager + textMem *text.Memory + knowledge *knowledge.Store + tracker *tracker.Tracker + cfgReg *internalConfig.ConfigRegistry + pluginReg *plugin.Registry + eventBus *events.Bus + statusProvider agentCore.StatusProvider +} + +func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker, cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus, sp agentCore.StatusProvider) *Handler { var idx *memory.Indexer if mem != nil { idx = memory.NewIndexer(mem) } return &Handler{ - supervisor: sup, - memory: mem, - indexer: idx, - skills: sk, - lua: lua, - config: cfg, - startTime: time.Now(), - iom: iom, - textMem: tm, - knowledge: ks, - tracker: tr, - cfgReg: cr, - pluginReg: pr, - eventBus: evBus, + supervisor: sup, + memory: mem, + indexer: idx, + skills: sk, + lua: lua, + config: cfg, + startTime: time.Now(), + iom: iom, + textMem: tm, + knowledge: ks, + tracker: tr, + cfgReg: cr, + pluginReg: pr, + eventBus: evBus, + statusProvider: sp, } } @@ -86,6 +102,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/tracker/", h.handleTracker) mux.HandleFunc("/api/v1/chat", h.handleChat) mux.HandleFunc("/api/v1/chat/events", h.handleChatEvents) + mux.HandleFunc("/api/v1/kernel", h.handleKernel) mux.HandleFunc("/v1/chat/completions", h.handleOpenAICompletions) mux.HandleFunc("/", h.handleStatic) } @@ -105,6 +122,18 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) { }) } +func (h *Handler) handleKernel(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if h.statusProvider == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "kernel status provider not available"}) + return + } + writeJSON(w, http.StatusOK, h.statusProvider.GetKernelStatus()) +} + func (h *Handler) handleAgents(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -847,7 +876,7 @@ func (h *Handler) handleTracker(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write(webuiHTML) + w.Write([]byte(dashboardHTML)) return } http.NotFound(w, r) @@ -863,94 +892,3 @@ func writeJSON(w http.ResponseWriter, status int, data interface{}) { w.WriteHeader(status) json.NewEncoder(w).Encode(data) } - -var webuiHTML = []byte(` - - - - -HomeAgent Dashboard - - - - -
-
-
-
-
-
-
-
- - -`) diff --git a/internal/plugins/webui/handler_test.go b/internal/plugins/webui/handler_test.go index 093cc25..7642db6 100644 --- a/internal/plugins/webui/handler_test.go +++ b/internal/plugins/webui/handler_test.go @@ -34,7 +34,7 @@ func newTestHandler(t *testing.T) (*Handler, *supervisor.Daemon) { sup := supervisor.New(cfg) sup.Start() - return NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil, events.NewBus()), sup + return NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil, events.NewBus(), nil), sup } func TestHandleStatus(t *testing.T) { @@ -138,7 +138,7 @@ func TestHandleKnowledgeSearch(t *testing.T) { sup.Start() defer sup.Shutdown() - h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil, nil, nil, events.NewBus()) + h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil, nil, nil, events.NewBus(), nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge?q=test", nil) w := httptest.NewRecorder() @@ -170,7 +170,7 @@ func TestHandleKnowledgeCreate(t *testing.T) { sup.Start() defer sup.Shutdown() - h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil, nil, nil, events.NewBus()) + h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil, nil, nil, events.NewBus(), nil) body := `{"name":"new_doc","content":"fresh content"}` req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", strings.NewReader(body)) @@ -235,7 +235,7 @@ func TestHandleTrackerStats(t *testing.T) { sup.Start() defer sup.Shutdown() - h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, tr, nil, nil, events.NewBus()) + h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, tr, nil, nil, events.NewBus(), nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/tracker", nil) w := httptest.NewRecorder() @@ -415,7 +415,7 @@ func TestSettingsAPIFlow(t *testing.T) { defer sup.Shutdown() pluginReg := plugin.NewRegistry() - h := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, cfgReg, pluginReg, events.NewBus()) + h := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, cfgReg, pluginReg, events.NewBus(), nil) t.Run("GET_settings_lists_keys_and_plugins", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil) @@ -522,7 +522,7 @@ func TestSettingsAPIFlow(t *testing.T) { }) t.Run("settings_not_available_without_registry", func(t *testing.T) { - h2 := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, nil, nil, events.NewBus()) + h2 := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, nil, nil, events.NewBus(), nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil) w := httptest.NewRecorder() h2.handleSettings(w, req) @@ -548,7 +548,7 @@ func TestSettingsWithPluginRegistry(t *testing.T) { defer sup.Shutdown() pluginReg := plugin.NewRegistry() - h := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, cfgReg, pluginReg, events.NewBus()) + h := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, cfgReg, pluginReg, events.NewBus(), nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil) w := httptest.NewRecorder() @@ -622,7 +622,7 @@ func TestHandleCompletionsEndToEnd(t *testing.T) { sup.Start() defer sup.Shutdown() - h := NewHandler(sup, nil, nil, nil, &types.Config{}, iom, nil, nil, nil, nil, nil, events.NewBus()) + h := NewHandler(sup, nil, nil, nil, &types.Config{}, iom, nil, nil, nil, nil, nil, events.NewBus(), nil) t.Run("POST_chat_completions_returns_echo", func(t *testing.T) { body := `{"model":"test","messages":[{"role":"user","content":"你好"}]}` @@ -655,7 +655,7 @@ func TestHandleCompletionsEndToEnd(t *testing.T) { }) t.Run("POST_chat_completions_no_iom_returns_503", func(t *testing.T) { - h2 := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, nil, nil, events.NewBus()) + h2 := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, nil, nil, events.NewBus(), nil) body := `{"messages":[{"role":"user","content":"hi"}]}` req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") diff --git a/internal/plugins/webui/plugin.go b/internal/plugins/webui/plugin.go index b6a454b..844aab1 100644 --- a/internal/plugins/webui/plugin.go +++ b/internal/plugins/webui/plugin.go @@ -4,6 +4,7 @@ import ( "log" "net/http" + agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core" agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" "gitcode.com/JianFeeeee/HomeAgent/internal/events" @@ -21,19 +22,20 @@ import ( // 包级依赖注入 — 由 main.go 在 Load() 前调用 Configure() 设置。 var ( - webuiAddr string - webuiSup *supervisor.Daemon - webuiMem *memory.GraphDB - webuiSK *skill.Manager - webuiLua *luaVM.VM - webuiCfg *types.Config - webuiIOM *agentIO.IOManager - webuiTM *text.Memory - webuiKS *knowledge.Store - webuiTR *tracker.Tracker - webuiCR *internalConfig.ConfigRegistry - webuiPR *plugin.Registry - webuiEvBus *events.Bus + webuiAddr string + webuiSup *supervisor.Daemon + webuiMem *memory.GraphDB + webuiSK *skill.Manager + webuiLua *luaVM.VM + webuiCfg *types.Config + webuiIOM *agentIO.IOManager + webuiTM *text.Memory + webuiKS *knowledge.Store + webuiTR *tracker.Tracker + webuiCR *internalConfig.ConfigRegistry + webuiPR *plugin.Registry + webuiEvBus *events.Bus + webuiStatusProvider agentCore.StatusProvider ) // Configure 注入 WebUI 插件需要的内核依赖。必须在 Load() 之前调用。 @@ -42,11 +44,13 @@ func Configure(addr string, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker, cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus, + sp agentCore.StatusProvider, ) { webuiAddr = addr webuiSup, webuiMem, webuiSK, webuiLua = sup, mem, sk, lua webuiCfg, webuiIOM, webuiTM, webuiKS = cfg, iom, tm, ks webuiTR, webuiCR, webuiPR, webuiEvBus = tr, cr, pr, evBus + webuiStatusProvider = sp } func init() { @@ -61,7 +65,7 @@ func init() { return New(name, addr, webuiSup, webuiMem, webuiSK, webuiLua, webuiCfg, webuiIOM, webuiTM, webuiKS, - webuiTR, webuiCR, webuiPR, webuiEvBus, + webuiTR, webuiCR, webuiPR, webuiEvBus, webuiStatusProvider, ), nil }) } @@ -85,6 +89,7 @@ type Plugin struct { cr *internalConfig.ConfigRegistry pr *plugin.Registry evBus *events.Bus + statusProvider agentCore.StatusProvider } func New(name, addr string, @@ -92,6 +97,7 @@ func New(name, addr string, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker, cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus, + sp agentCore.StatusProvider, ) *Plugin { return &Plugin{ name: name, @@ -99,13 +105,14 @@ func New(name, addr string, mux: http.NewServeMux(), sup: sup, mem: mem, sk: sk, lua: lua, cfg: cfg, iom: iom, tm: tm, ks: ks, tr: tr, cr: cr, pr: pr, evBus: evBus, + statusProvider: sp, } } func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { - h := NewHandler(p.sup, p.mem, p.sk, p.lua, p.cfg, p.iom, p.tm, p.ks, p.tr, p.cr, p.pr, p.evBus) + h := NewHandler(p.sup, p.mem, p.sk, p.lua, p.cfg, p.iom, p.tm, p.ks, p.tr, p.cr, p.pr, p.evBus, p.statusProvider) p.handler = h h.RegisterRoutes(p.mux) diff --git a/internal/sdk/plugin.go b/internal/sdk/plugin.go index fc92999..838015a 100644 --- a/internal/sdk/plugin.go +++ b/internal/sdk/plugin.go @@ -44,6 +44,7 @@ type StageContext struct { Response *string Phase Stage Memory []MemItem + NoMemory bool Extra map[string]interface{} } @@ -144,6 +145,13 @@ func (s *PluginSDK) InjectText(source, channel, text string) { } } +// InjectTextNoMemory 注入文本输入(不产生记忆)。适用于健康检查等无需记忆碎片的场景。 +func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) { + if s.iom != nil { + s.iom.InjectTextNoMemoryTo(source, channel, text) + } +} + func (s *PluginSDK) InjectTextSync(source, channel, text string) *agentIO.OutputEvent { if s.iom != nil { return s.iom.InjectTextSyncTo(source, channel, text) @@ -151,6 +159,14 @@ func (s *PluginSDK) InjectTextSync(source, channel, text string) *agentIO.Output return nil } +// InjectTextSyncNoMemory 注入文本输入(同步等待,不产生记忆)。 +func (s *PluginSDK) InjectTextSyncNoMemory(source, channel, text string) *agentIO.OutputEvent { + if s.iom != nil { + return s.iom.InjectTextSyncNoMemoryTo(source, channel, text) + } + return nil +} + func (s *PluginSDK) InjectInterruptText(source, channel, text string) { if s.iom != nil { s.iom.InjectInterruptText(source, channel, text)