diff --git a/internal/plugins/healthcheck/plugin.go b/internal/plugins/healthcheck/plugin.go index f690a9b..ea4428e 100644 --- a/internal/plugins/healthcheck/plugin.go +++ b/internal/plugins/healthcheck/plugin.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log" + "sort" "strings" "sync" "time" @@ -46,6 +47,7 @@ type Plugin struct { reports []llmReport sessionID string selfToolNames map[string]bool + checkMu sync.Mutex stopCh chan struct{} stopOnce sync.Once @@ -153,13 +155,16 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { p.selfToolNames["healthcheck"] = true s.RegisterTool("healthcheck", sdk.ToolDef{ Name: "healthcheck", - Description: "运行系统全面健康检查。先执行静态检查(插件/工具列表/记忆/知识库/文档),再启动 LLM 驱动检查:LLM 主动发现并逐个测试各插件提供的工具,并通过 healthcheck_report 上报结果。返回详细的状态报告。", + Description: "运行系统全面健康检查。先执行静态检查(插件/工具列表/记忆/知识库/文档),再启动 LLM 驱动检查:LLM 主动发现并逐个测试各插件提供的工具,并通过 healthcheck_report 上报结果。返回详细的状态报告(每个插件、每个工具一条结果)。可用 plugin 参数只针对指定插件检查。", Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{}, + "type": "object", + "properties": map[string]interface{}{ + "plugin": map[string]interface{}{"type": "string", "description": "可选:指定只检查该插件的健康状态(列出插件工具并逐一测试),不填则检查全部插件"}, + }, }, }, func(args map[string]interface{}) (interface{}, error) { - return p.runFullCheck(s) + plugin, _ := args["plugin"].(string) + return p.runFullCheck(s, plugin) }) p.selfToolNames["healthcheck_plugins"] = true @@ -298,7 +303,7 @@ func (p *Plugin) startAutoCheck(s *sdk.PluginSDK, interval time.Duration) { } func (p *Plugin) runAutoCheck(s *sdk.PluginSDK) { - result, err := p.runFullCheck(s) + result, err := p.runFullCheck(s, "") if err != nil { log.Printf("[healthcheck] auto-check error: %v", err) return @@ -307,10 +312,14 @@ func (p *Plugin) runAutoCheck(s *sdk.PluginSDK) { 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 + elapsedMs, _ := resp["elapsed_ms"].(int64) + + // 失败项明细(定位问题用) + if checks, ok := resp["checks"].([]checkResult); ok { + for _, c := range checks { + if !c.Pass { + log.Printf("[healthcheck] FAILED %s: status=%s detail=%s", c.Name, c.Status, c.Detail) + } } } @@ -319,7 +328,7 @@ func (p *Plugin) runAutoCheck(s *sdk.PluginSDK) { Passed: passed, Failed: failed, Total: total, - ElapsedMs: elapsed, + ElapsedMs: elapsedMs, } p.mu.Lock() p.perfData.LastCheck = pt.Time @@ -329,10 +338,15 @@ func (p *Plugin) runAutoCheck(s *sdk.PluginSDK) { } p.mu.Unlock() - log.Printf("[healthcheck] auto-check complete: passed=%d failed=%d total=%d", passed, failed, total) + log.Printf("[healthcheck] auto-check complete: passed=%d failed=%d total=%d elapsed=%dms", passed, failed, total, elapsedMs) } -func (p *Plugin) runFullCheck(s *sdk.PluginSDK) (interface{}, error) { +func (p *Plugin) runFullCheck(s *sdk.PluginSDK, pluginFilter string) (interface{}, error) { + // 单飞:auto-check 与手动调用并发时,避免互相 reset 虚拟实例或 LLM 检查串扰 + p.checkMu.Lock() + defer p.checkMu.Unlock() + start := time.Now() + results := []checkResult{} // 每轮自检前重置隔离虚拟实例,清空上轮测试数据(仅影响虚拟空间,不碰生产存储)。 @@ -340,10 +354,10 @@ func (p *Plugin) runFullCheck(s *sdk.PluginSDK) (interface{}, error) { log.Printf("[healthcheck] selftest reset: %v", err) } - pluginResult := p.checkPluginsRaw(s) + pluginResult := p.checkPluginsRaw(s, pluginFilter) results = append(results, pluginResult...) - toolResult := p.checkToolsRaw(s) + toolResult := p.checkToolsRaw(s, pluginFilter) results = append(results, toolResult...) if s.Memory() != nil { @@ -365,7 +379,8 @@ func (p *Plugin) runFullCheck(s *sdk.PluginSDK) (interface{}, error) { } if s.LLM() != nil { - results = append(results, p.testLLMDriven(s)) + results = append(results, p.testLLMDriven(s, pluginFilter)) + results = append(results, p.reportsToChecks()...) } else { results = append(results, checkResult{Name: "llm_discovery", Status: "skip", Detail: "LLM Provider 未初始化", Pass: true}) } @@ -380,20 +395,22 @@ func (p *Plugin) runFullCheck(s *sdk.PluginSDK) (interface{}, error) { } } - summary := fmt.Sprintf("通过 %d / %d, 失败 %d", passCount, len(results), failCount) + elapsed := time.Since(start) + summary := fmt.Sprintf("通过 %d / %d, 失败 %d, 耗时 %v", passCount, len(results), failCount, elapsed.Round(time.Millisecond)) return map[string]interface{}{ - "status": "ok", - "summary": summary, - "total": len(results), - "passed": passCount, - "failed": failCount, - "checks": results, + "status": "ok", + "summary": summary, + "total": len(results), + "passed": passCount, + "failed": failCount, + "elapsed_ms": elapsed.Milliseconds(), + "checks": results, }, nil } func (p *Plugin) checkPlugins(s *sdk.PluginSDK) (interface{}, error) { - results := p.checkPluginsRaw(s) + results := p.checkPluginsRaw(s, "") return map[string]interface{}{ "status": "ok", "plugins": results, @@ -401,7 +418,8 @@ func (p *Plugin) checkPlugins(s *sdk.PluginSDK) (interface{}, error) { }, nil } -func (p *Plugin) checkPluginsRaw(s *sdk.PluginSDK) []checkResult { +// checkPluginsRaw 按插件逐个产出检查项(每个插件一条:加载状态 + 工具数)。 +func (p *Plugin) checkPluginsRaw(s *sdk.PluginSDK, pluginFilter string) []checkResult { if s.PluginMgr() == nil { return []checkResult{{Name: "plugins", Status: "skip", Detail: "插件注册表未初始化", Pass: true}} } @@ -410,16 +428,66 @@ func (p *Plugin) checkPluginsRaw(s *sdk.PluginSDK) []checkResult { if names == nil { names = []string{} } - return []checkResult{{ - Name: "plugins", - Status: "ok", - Detail: fmt.Sprintf("已加载 %d 个插件: %v", len(names), names), - Pass: true, - }} + sort.Strings(names) + disabled := map[string]bool{} + for _, d := range s.PluginMgr().ListDisabledPlugins() { + disabled[d.Name] = true + } + + // 工具按插件聚合统计 + toolCount := map[string]int{} + if s.Tool() != nil { + for _, def := range s.Tool().GetToolDefs() { + pl := def.Plugin + if pl == "" { + pl = "unknown" + } + toolCount[pl]++ + } + } + + var out []checkResult + for _, name := range names { + if pluginFilter != "" && name != pluginFilter { + continue + } + status, detail := "ok", "" + pass := true + if disabled[name] { + status, detail, pass = "disabled", "插件已禁用", true + } + if n := toolCount[name]; n > 0 { + if detail != "" { + detail += ", " + } + detail += fmt.Sprintf("%d 个工具", n) + } else if !disabled[name] { + status, detail, pass = "warn", "插件已加载但未注册工具", true + } + out = append(out, checkResult{Name: "plugin/" + name, Status: status, Detail: detail, Pass: pass}) + } + + if pluginFilter != "" { + matched := false + for _, name := range names { + if name == pluginFilter { + matched = true + break + } + } + if !matched { + out = append(out, checkResult{Name: "plugin/" + pluginFilter, Status: "fail", Detail: "插件未加载或不存在", Pass: false}) + } + } + + if len(out) == 0 { + out = append(out, checkResult{Name: "plugins", Status: "ok", Detail: "无已加载插件", Pass: true}) + } + return out } func (p *Plugin) listAllTools(s *sdk.PluginSDK) (interface{}, error) { - tools := p.collectAllTools(s) + tools := p.collectAllTools(s, "") return map[string]interface{}{ "status": "ok", "count": len(tools), @@ -427,17 +495,34 @@ func (p *Plugin) listAllTools(s *sdk.PluginSDK) (interface{}, error) { }, nil } -func (p *Plugin) checkToolsRaw(s *sdk.PluginSDK) []checkResult { - tools := p.collectAllTools(s) +func (p *Plugin) checkToolsRaw(s *sdk.PluginSDK, pluginFilter string) []checkResult { + tools := p.collectAllTools(s, pluginFilter) + byPlugin := map[string]int{} + for _, t := range tools { + src := t.Source + if src == "" { + src = "unknown" + } + byPlugin[src]++ + } + names := make([]string, 0, len(byPlugin)) + for n := range byPlugin { + names = append(names, n) + } + sort.Strings(names) + var parts []string + for _, n := range names { + parts = append(parts, fmt.Sprintf("%s=%d", n, byPlugin[n])) + } return []checkResult{{ Name: "tools", Status: "ok", - Detail: fmt.Sprintf("系统中共 %d 个工具", len(tools)), + Detail: fmt.Sprintf("共 %d 个工具(%s)", len(tools), strings.Join(parts, ", ")), Pass: true, }} } -func (p *Plugin) collectAllTools(s *sdk.PluginSDK) []toolInfo { +func (p *Plugin) collectAllTools(s *sdk.PluginSDK, pluginFilter string) []toolInfo { seen := map[string]bool{} var tools []toolInfo @@ -451,11 +536,20 @@ func (p *Plugin) collectAllTools(s *sdk.PluginSDK) []toolInfo { if s.Tool() != nil { for _, def := range s.Tool().GetToolDefs() { - addTool(def.Name, "plugin", def.Description) + if pluginFilter != "" && def.Plugin != pluginFilter { + continue + } + src := def.Plugin + if src == "" { + src = "unknown" + } + addTool(def.Name, src, def.Description) } - for _, def := range s.Tool().GetAllTools() { - addTool(def.Name, "device", def.Description) + if pluginFilter == "" { + for _, def := range s.Tool().GetAllTools() { + addTool(def.Name, "device", def.Description) + } } } @@ -586,7 +680,7 @@ func (p *Plugin) testDocStoreRaw(s *sdk.PluginSDK) checkResult { } } -func (p *Plugin) testLLMDriven(s *sdk.PluginSDK) checkResult { +func (p *Plugin) testLLMDriven(s *sdk.PluginSDK, pluginFilter string) checkResult { llmName := s.LLM().CurrentSource() if llmName == "" { return checkResult{Name: "llm_discovery", Status: "skip", Detail: "无可用 LLM Provider", Pass: true} @@ -597,7 +691,7 @@ func (p *Plugin) testLLMDriven(s *sdk.PluginSDK) checkResult { defer cancel() // 收集所有工具定义(排除健康检查自身的工具以避免循环测试) - toolDefs := p.collectToolDefsForLLM(s) + toolDefs := p.collectToolDefsForLLM(s, pluginFilter) if len(toolDefs) == 0 { return checkResult{Name: "llm_discovery", Status: "skip", Detail: "没有可测试的工具", Pass: true} @@ -610,7 +704,7 @@ func (p *Plugin) testLLMDriven(s *sdk.PluginSDK) checkResult { p.mu.Unlock() // 构建 prompt - prompt := p.buildDiscoveryPrompt(toolDefs) + prompt := p.buildDiscoveryPrompt(toolDefs, pluginFilter) msgs := []sdk.LLMMessage{{Role: "user", Content: prompt}} tools := convertToolDefs(toolDefs) @@ -656,7 +750,7 @@ func (p *Plugin) testLLMDriven(s *sdk.PluginSDK) checkResult { elapsed := time.Since(start).Round(time.Millisecond) - detail := fmt.Sprintf("Provider %s, %d 轮对话, %d 次工具调用, %d 份报告, 耗时 %v", + detail := fmt.Sprintf("Provider %s, %d 轮对话, %d 次工具调用, %d 份工具报告, 耗时 %v", llmName, turnCount, toolCallCount, reportCount, elapsed) return checkResult{ @@ -667,10 +761,29 @@ func (p *Plugin) testLLMDriven(s *sdk.PluginSDK) checkResult { } } +// reportsToChecks 把 LLM 逐工具上报的 healthcheck_report 明细转为细粒度检查项。 +func (p *Plugin) reportsToChecks() []checkResult { + p.mu.Lock() + reports := append([]llmReport{}, p.reports...) + p.mu.Unlock() + + out := make([]checkResult, 0, len(reports)) + for _, r := range reports { + pass := r.Status == "ok" || r.Status == "skip" + out = append(out, checkResult{ + Name: "llm_tool/" + r.ToolName, + Status: r.Status, + Detail: r.Detail, + Pass: pass, + }) + } + return out +} + // collectToolDefsForLLM 收集全部已注册的工具定义供 LLM 发现和测试。 // 动态排除本插件自身注册的工具(通过 selfToolNames),避免 LLM 自我循环调用; // 且仅保留"只读/轻量验证"类工具(白名单语义),防止 LLM 自检污染生产数据或引发副作用。 -func (p *Plugin) collectToolDefsForLLM(s *sdk.PluginSDK) []sdk.ToolDef { +func (p *Plugin) collectToolDefsForLLM(s *sdk.PluginSDK, pluginFilter string) []sdk.ToolDef { seen := map[string]bool{} var defs []sdk.ToolDef @@ -687,10 +800,15 @@ func (p *Plugin) collectToolDefsForLLM(s *sdk.PluginSDK) []sdk.ToolDef { if s.Tool() != nil { for _, d := range s.Tool().GetToolDefs() { + if pluginFilter != "" && d.Plugin != pluginFilter { + continue + } addDef(d) } - for _, d := range s.Tool().GetAllTools() { - addDef(d) + if pluginFilter == "" { + for _, d := range s.Tool().GetAllTools() { + addDef(d) + } } } @@ -726,9 +844,13 @@ func isSafeReadonlyTool(name string) bool { } // buildDiscoveryPrompt 为 LLM 构造工具探索 prompt。 -func (p *Plugin) buildDiscoveryPrompt(toolDefs []sdk.ToolDef) string { +func (p *Plugin) buildDiscoveryPrompt(toolDefs []sdk.ToolDef, pluginFilter string) string { + target := "全部插件" + if pluginFilter != "" { + target = "插件「" + pluginFilter + "」" + } var b strings.Builder - b.WriteString(fmt.Sprintf(`你是一名系统健康检查专家。以下是系统中各插件提供的 %d 个工具(已自动排除健康检查插件自身工具及所有会写/删/改生产数据或产生外部副作用的工具,以下均为只读/查询/列表类工具): + b.WriteString(fmt.Sprintf(`你是一名系统健康检查专家。以下是%s提供的 %d 个工具(已自动排除健康检查插件自身工具及所有会写/删/改生产数据或产生外部副作用的工具,以下均为只读/查询/列表类工具): 你的任务是:逐一尝试调用这些工具,验证它们是否正常工作,并对于每个工具使用 healthcheck_report 工具上报测试结果。 @@ -742,8 +864,9 @@ func (p *Plugin) buildDiscoveryPrompt(toolDefs []sdk.ToolDef) string { - 所有工具均为只读、无副作用,可放心调用 - 尽可能覆盖所有工具 - 每个工具只需测试一次 +- 每个工具都必须单独调用 healthcheck_report 上报,不要合并 -开始测试!`, len(toolDefs))) +开始测试!`, target, len(toolDefs))) return b.String() } diff --git a/internal/plugins/healthcheck/plugin_test.go b/internal/plugins/healthcheck/plugin_test.go index 30e0c85..56fea78 100644 --- a/internal/plugins/healthcheck/plugin_test.go +++ b/internal/plugins/healthcheck/plugin_test.go @@ -458,7 +458,7 @@ func TestCollectToolDefsForLLMNoMutating(t *testing.T) { Tool: sdk.NewTool(stage, agentIO.NewIOManager()), }) - got := p.collectToolDefsForLLM(s) + got := p.collectToolDefsForLLM(s, "") allowed := map[string]bool{} for _, d := range got { allowed[d.Name] = true @@ -478,4 +478,85 @@ func TestCollectToolDefsForLLMNoMutating(t *testing.T) { } } +// TestCollectToolDefsForLLMPluginFilter 验证指定插件时只收集该插件的只读工具。 +func TestCollectToolDefsForLLMPluginFilter(t *testing.T) { + p := &Plugin{name: "healthcheck", selfToolNames: map[string]bool{"healthcheck": true, "healthcheck_report": true}} + + stage := agentCore.NewStageHost() + defs := []sdk.ToolDef{ + {Name: "memory_recall", Plugin: "memory", Description: "recall"}, + {Name: "memory_commit", Plugin: "memory", Description: "commit"}, + {Name: "doc_query", Plugin: "doc", Description: "query"}, + {Name: "doc_commit", Plugin: "doc", Description: "commit doc"}, + {Name: "knowledge_search", Plugin: "knowledge", Description: "search"}, + {Name: "files_list", Plugin: "files", Description: "list"}, + {Name: "files_write", Plugin: "files", Description: "write"}, + {Name: "cmd_run", Plugin: "cmd", Description: "run cmd"}, + } + for _, d := range defs { + d := d + stage.RegisterTool(d.Name, d, func(map[string]interface{}) (interface{}, error) { return nil, nil }) + } + + tc := newToolCapture() + s := newTestSDK(sdk.SDKConfig{ + RegTool: tc.RegisterTool, + RegStage: tc.RegisterStage, + RegAPI: tc.RegisterAPI, + Tool: sdk.NewTool(stage, agentIO.NewIOManager()), + }) + + got := p.collectToolDefsForLLM(s, "files") + allowed := map[string]bool{} + for _, d := range got { + allowed[d.Name] = true + } + if len(got) != 1 || !allowed["files_list"] { + t.Fatalf("expected only files_list for files plugin, got %v", allowed) + } + for _, name := range []string{"memory_recall", "doc_query", "knowledge_search", "files_write"} { + if allowed[name] { + t.Errorf("tool %q must NOT be in files-filtered set", name) + } + } + + gotMemory := p.collectToolDefsForLLM(s, "memory") + if len(gotMemory) != 1 || gotMemory[0].Name != "memory_recall" { + t.Fatalf("expected only memory_recall for memory plugin, got %v", gotMemory) + } + + // 全量时不丢任何只读工具 + all := p.collectToolDefsForLLM(s, "") + if len(all) != 4 { + t.Fatalf("expected 4 readonly tools unfiltered, got %d", len(all)) + } +} + +// TestReportsToChecks 验证 LLM 上报明细转为细粒度检查项(ok/skip 通过, fail 失败)。 +func TestReportsToChecks(t *testing.T) { + p := &Plugin{} + p.reports = []llmReport{ + {ToolName: "qq_get_message", Status: "ok", Detail: "正常"}, + {ToolName: "doc_query", Status: "skip", Detail: "无可测数据"}, + {ToolName: "knowledge_search", Status: "fail", Detail: "搜索超时"}, + } + checks := p.reportsToChecks() + if len(checks) != 3 { + t.Fatalf("expected 3 checks, got %d", len(checks)) + } + byName := map[string]checkResult{} + for _, c := range checks { + byName[c.Name] = c + } + if !byName["llm_tool/qq_get_message"].Pass { + t.Error("ok report must pass") + } + if !byName["llm_tool/doc_query"].Pass { + t.Error("skip report must pass") + } + if byName["llm_tool/knowledge_search"].Pass { + t.Error("fail report must NOT pass") + } +} + diff --git a/internal/plugins/mcp/sse.go b/internal/plugins/mcp/sse.go index 7891ed3..41a2a06 100644 --- a/internal/plugins/mcp/sse.go +++ b/internal/plugins/mcp/sse.go @@ -6,24 +6,64 @@ import ( "fmt" "io" "net/http" + "strings" ) -// SSETransport 通过 HTTP POST 进行 JSON-RPC 通信(简化版,非流式) +// SSETransport 通过 HTTP POST 进行 JSON-RPC 通信。 +// 兼容两种服务端响应:application/json 直连响应,以及 Streamable HTTP +// 的异步响应(HTTP 202 + text/event-stream 的 SSE data 帧)。 type SSETransport struct { - url string - client *http.Client - pending map[int]chan *rpcResponse - done chan struct{} + url string + client *http.Client } func NewSSETransport(url string) *SSETransport { return &SSETransport{ - url: url, - client: &http.Client{}, - pending: make(map[int]chan *rpcResponse), - done: make(chan struct{}), + url: url, + client: &http.Client{}, } } + +// parseResponseBody 根据 Content-Type 解析 JSON-RPC 响应 +func parseResponseBody(contentType string, body []byte) (*rpcResponse, error) { + if strings.Contains(contentType, "text/event-stream") { + // SSE 流:逐行提取 data: 帧 + var lastJSON []byte + for _, line := range strings.Split(string(body), "\n") { + line = strings.TrimRight(line, "\r") + if strings.HasPrefix(line, "data:") { + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "" || data == "[DONE]" { + continue + } + var frame map[string]json.RawMessage + if err := json.Unmarshal([]byte(data), &frame); err == nil { + if _, isResp := frame["id"]; isResp || frame["result"] != nil || frame["error"] != nil { + lastJSON = []byte(data) + } + } + } + } + if len(lastJSON) == 0 { + return nil, fmt.Errorf("SSE 流中未找到 JSON-RPC 响应帧: %s", truncate(string(body), 300)) + } + body = lastJSON + } + + var rpcResp rpcResponse + if err := json.Unmarshal(body, &rpcResp); err != nil { + return nil, fmt.Errorf("unmarshal response: %w, body=%s", err, truncate(string(body), 300)) + } + return &rpcResp, nil +} + +func truncate(s string, n int) string { + if len(s) > n { + return s[:n] + "..." + } + return s +} + func (t *SSETransport) Send(req *rpcRequest) (*rpcResponse, error) { data, err := json.Marshal(req) if err != nil { @@ -35,6 +75,7 @@ func (t *SSETransport) Send(req *rpcRequest) (*rpcResponse, error) { return nil, fmt.Errorf("http request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json, text/event-stream") resp, err := t.client.Do(httpReq) if err != nil { @@ -47,15 +88,16 @@ func (t *SSETransport) Send(req *rpcRequest) (*rpcResponse, error) { return nil, fmt.Errorf("read body: %w", err) } - var rpcResp rpcResponse - if err := json.Unmarshal(body, &rpcResp); err != nil { - return nil, fmt.Errorf("unmarshal response: %w", err) + ct := resp.Header.Get("Content-Type") + // 非 2xx:尝试提取错误信息 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("http status %d: %s", resp.StatusCode, truncate(string(body), 300)) } - return &rpcResp, nil + return parseResponseBody(ct, body) } func (t *SSETransport) Close() error { - close(t.done) + t.client.CloseIdleConnections() return nil }