healthcheck: 存量改进; mcp: sse 改进

This commit is contained in:
JianFeeeee
2026-08-14 16:14:14 +08:00
parent c820280f16
commit 2e47ceed8b
3 changed files with 309 additions and 63 deletions

View File

@ -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()
}

View File

@ -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")
}
}

View File

@ -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
}