diff --git a/cmd/homed/main.go b/cmd/homed/main.go index f279507..9db220d 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -367,7 +367,11 @@ func main() { // 布局、预处理、解码、运行时全部属于 provider 内部实现。 // provider 名为空时禁用多模态向量检索,退回纯 fastText 文本路径。 var multimodalSpace vector.MultimodalEmbedder + // 这两个值只用于状态报告(healthcheck_kernel 的 onnx 段): + // 「配了哪个 provider」与「为什么没启用」,避免只能看到 false 却不知原因。 + var mmProviderName, mmErr string if mmProvider := cfgReg.GetString("core.memory.multimodal_space.provider", ""); mmProvider != "" { + mmProviderName = mmProvider opts := map[string]string{} const optPrefix = "core.memory.multimodal_space.options." for _, key := range cfgReg.List("core.memory.multimodal_space.options.") { @@ -375,10 +379,12 @@ func main() { } provider, err := embedding.Open(mmProvider, embedding.Config{Options: opts}) if err != nil { + mmErr = err.Error() log.Printf("[homed] warning: 多模态向量 provider %q 打开失败: %v(多模态向量检索已禁用;已注册: %s)", mmProvider, err, strings.Join(embedding.Names(), ", ")) } else if adapted, err := vector.AdaptProvider(provider); err != nil { provider.Close() + mmErr = err.Error() log.Printf("[homed] warning: 多模态向量 provider %q 元数据不合法: %v(多模态向量检索已禁用)", mmProvider, err) } else { multimodalSpace = adapted @@ -533,6 +539,8 @@ func main() { EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""), Embedder: embedder, MultimodalSpace: multimodalSpace, + EmbeddingProvider: mmProviderName, + EmbeddingError: mmErr, StageHost: stageHost, EventBus: evBus, ThinkingEnabled: cfg.LLM.ThinkingEnabled, diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index bb7cc64..d2afea0 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -154,6 +154,12 @@ type Agent struct { // 也可以是外部 API 客户端;两者共享同一套 L0/L2/L3 向量缓存与检索基础设施。 multimodalSpace vector.MultimodalEmbedder + // embeddingProvider 是配置里指定的统一向量空间 provider 名; + // embeddingError 是打开/适配失败的原因(成功时为空)。 + // 二者只用于状态报告:区分「没配」「配了但打不开」「已启用」。 + embeddingProvider string + embeddingError string + // fusionCfg 控制文本路与视觉路的跨模态融合权重,可按模型实测结果配置。 fusionCfg CrossModalFusionConfig @@ -179,12 +185,16 @@ type AgentConfig struct { Indexer *memory.Indexer Tracker *tracker.Tracker - DocStore *document.Store - Knowledge *knowledge.Store - SocialStore *social.SocialStore - TextMemory *text.Memory - MediaStore *media.Store - MultimodalSpace vector.MultimodalEmbedder + DocStore *document.Store + Knowledge *knowledge.Store + SocialStore *social.SocialStore + TextMemory *text.Memory + MediaStore *media.Store + MultimodalSpace vector.MultimodalEmbedder + // EmbeddingProvider / EmbeddingError 是向量空间的配置身份与打开失败原因, + // 供 healthcheck_kernel 状态报告区分「未配置 / 打开失败 / 已启用」。 + EmbeddingProvider string + EmbeddingError string FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认 Personality *agentPkg.Personality PersonaStore PersonaStore // 人格设定的读写面(首启门禁 + persona_set 工具) @@ -256,46 +266,48 @@ func New(cfg AgentConfig) *Agent { } return &Agent{ - id: cfg.ID, - startTime: time.Now(), - provider: cfg.Provider, - providerManager: cfg.ProviderManager, - io: cfg.IO, - memory: cfg.Memory, - indexer: cfg.Indexer, - tracker: cfg.Tracker, - context: rc, - systemPrompt: cfg.SystemPrompt, - ctx: ctx, - cancel: cancel, - docStore: cfg.DocStore, - knowledge: cfg.Knowledge, - social: cfg.SocialStore, - textMem: cfg.TextMemory, - mediaStore: cfg.MediaStore, - personality: cfg.Personality, - personaStore: cfg.PersonaStore, - pluginReg: cfg.PluginReg, - pluginDir: cfg.PluginDir, - distillInterval: cfg.DistillInterval, - archiveInterval: cfg.ArchiveInterval, - reviewInterval: cfg.ReviewInterval, - mergeInterval: cfg.MergeInterval, - maxContextSize: cfg.MaxContextSize, - stageHost: cfg.StageHost, - skillIndex: cfg.SkillIndexProvider, - eventBus: cfg.EventBus, - selfInputCh: make(chan selfInputMsg, 64), - childTasks: make(map[string]*childTaskState), - interceptCh: make(chan *agentIO.InputEvent, 64), - pluginHealth: newPluginHealthTracker(), - thinkingEnabled: cfg.ThinkingEnabled, - inputCfg: cfg.InputProcessing, - embedder: embedder, - multimodalSpace: cfg.MultimodalSpace, - fusionCfg: cfg.FusionCfg, - noMergeMarkers: make(map[string]int), - lastInput: make(map[string]time.Time), + id: cfg.ID, + startTime: time.Now(), + provider: cfg.Provider, + providerManager: cfg.ProviderManager, + io: cfg.IO, + memory: cfg.Memory, + indexer: cfg.Indexer, + tracker: cfg.Tracker, + context: rc, + systemPrompt: cfg.SystemPrompt, + ctx: ctx, + cancel: cancel, + docStore: cfg.DocStore, + knowledge: cfg.Knowledge, + social: cfg.SocialStore, + textMem: cfg.TextMemory, + mediaStore: cfg.MediaStore, + personality: cfg.Personality, + personaStore: cfg.PersonaStore, + pluginReg: cfg.PluginReg, + pluginDir: cfg.PluginDir, + distillInterval: cfg.DistillInterval, + archiveInterval: cfg.ArchiveInterval, + reviewInterval: cfg.ReviewInterval, + mergeInterval: cfg.MergeInterval, + maxContextSize: cfg.MaxContextSize, + stageHost: cfg.StageHost, + skillIndex: cfg.SkillIndexProvider, + eventBus: cfg.EventBus, + selfInputCh: make(chan selfInputMsg, 64), + childTasks: make(map[string]*childTaskState), + interceptCh: make(chan *agentIO.InputEvent, 64), + pluginHealth: newPluginHealthTracker(), + thinkingEnabled: cfg.ThinkingEnabled, + inputCfg: cfg.InputProcessing, + embedder: embedder, + multimodalSpace: cfg.MultimodalSpace, + embeddingProvider: cfg.EmbeddingProvider, + embeddingError: cfg.EmbeddingError, + fusionCfg: cfg.FusionCfg, + noMergeMarkers: make(map[string]int), + lastInput: make(map[string]time.Time), } } diff --git a/internal/agent/core/status.go b/internal/agent/core/status.go index fa33081..d3644eb 100644 --- a/internal/agent/core/status.go +++ b/internal/agent/core/status.go @@ -198,6 +198,14 @@ func (a *Agent) GetKernelStatus() *KernelStatus { trk = a.tracker } + // 注意:knowledge 在 collectKernelStatus 里是**接口**参数, + // 而 (*knowledge.Store)(nil) 塞进接口后 `ks != nil` 仍为真 → 调 List() 直接 panic。 + // 所以这里必须先判具体指针再进行接口赋值(healthcheck_kernel 会走到这条路径)。 + var knowledgeLister interface{ List() []string } + if a.knowledge != nil { + knowledgeLister = a.knowledge + } + ks := collectKernelStatus( a.startTime, string(a.id), @@ -207,15 +215,43 @@ func (a *Agent) GetKernelStatus() *KernelStatus { a.io, a.pluginReg, a.memory, - a.knowledge, + knowledgeLister, a.docStore, textMem, socialStore, trk, ) + ks.ONNX = a.onnxStatus() return ks } +// onnxStatus 汇总统一多模态向量空间(ONNX 模型)的启用状态。 +// +// 判据是 Loaded()(provider 真正打开且元数据合法),**不是**「配置里写了 provider」—— +// 后者在模型缺失 / 运行时缺失时也为真,拿它当判据就是假绿。 +func (a *Agent) onnxStatus() sdk.ONNXStatus { + st := sdk.ONNXStatus{Provider: a.embeddingProvider} + if a.multimodalSpace != nil && a.multimodalSpace.Loaded() { + st.Enabled = true + st.Dim = a.multimodalSpace.Dim() + st.Fingerprint = a.multimodalSpace.Fingerprint() + // 模态是可选能力:只有底层 provider 报出来时才带出。 + if mr, ok := a.multimodalSpace.(interface{ Modalities() []string }); ok { + st.Modalities = mr.Modalities() + } + return st + } + switch { + case a.embeddingError != "": + st.Reason = "打开失败: " + a.embeddingError + case a.embeddingProvider == "": + st.Reason = "未配置统一向量空间 provider(走词嵌入/TF-IDF 回退路径)" + default: + st.Reason = "provider 未加载" + } + return st +} + var _ StatusProvider = (*Agent)(nil) var _ sdk.StatusAPI = (*Agent)(nil) diff --git a/internal/agent/core/status_onnx_test.go b/internal/agent/core/status_onnx_test.go new file mode 100644 index 0000000..8d5eee2 --- /dev/null +++ b/internal/agent/core/status_onnx_test.go @@ -0,0 +1,103 @@ +package core + +import ( + "strings" + "testing" + "time" + + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" +) + +// statusSpace 是带模态元数据的假统一空间(ProviderAdapter 的 Modalities() 形状)。 +type statusSpace struct{ fakeSpace } + +func (statusSpace) Modalities() []string { return []string{"text", "image"} } + +// statusSpaceUnloaded 模拟「provider 建了但没加载成功」。 +type statusSpaceUnloaded struct{ fakeSpace } + +func (statusSpaceUnloaded) Loaded() bool { return false } + +// healthcheck_kernel 必须能回答两件事:内核版本号、以及**是否真的启用了 ONNX 模型**。 +// +// 判据要点:只有 provider 真正 Loaded() 才算启用 ——「配置里写了 provider」不算, +// 否则模型缺失/运行时缺失时会报成已启用(假绿)。 +func TestKernelStatusReportsVersionAndONNX(t *testing.T) { + t.Run("已启用:带出 provider/维度/指纹/模态", func(t *testing.T) { + a := &Agent{ + io: agentIO.NewIOManager(), + multimodalSpace: statusSpace{}, + embeddingProvider: "chineseclip", + } + st := a.GetKernelStatus() + if !st.ONNX.Enabled { + t.Fatal("Loaded() 为真时 onnx.enabled 必须为真") + } + if st.ONNX.Provider != "chineseclip" || st.ONNX.Dim != 2 || st.ONNX.Fingerprint != "fake-space" { + t.Fatalf("onnx 身份字段不对: %+v", st.ONNX) + } + if len(st.ONNX.Modalities) != 2 || st.ONNX.Reason != "" { + t.Fatalf("模态/原因不对: %+v", st.ONNX) + } + // 内核版本号必须随状态一起报:人格卡要求「版本以运行时快照为准」靠的就是这一项 + if st.Build.Version == "" || st.Build.KernelName == "" { + t.Fatalf("build 段缺少版本/内核名: %+v", st.Build) + } + }) + + t.Run("打开失败:enabled=false 且给出具体原因", func(t *testing.T) { + a := &Agent{ + io: agentIO.NewIOManager(), + embeddingProvider: "chineseclip", + embeddingError: `embedding: open provider "chineseclip": model dir missing`, + } + st := a.GetKernelStatus() + if st.ONNX.Enabled { + t.Fatal("打开失败时不能报 enabled") + } + if st.ONNX.Provider != "chineseclip" { + t.Fatalf("未启用时仍应带出配置的 provider: %+v", st.ONNX) + } + if !strings.Contains(st.ONNX.Reason, "model dir missing") { + t.Fatalf("原因应包含具体错误: %q", st.ONNX.Reason) + } + }) + + t.Run("未配置:说明会走回退路径", func(t *testing.T) { + a := &Agent{io: agentIO.NewIOManager()} + st := a.GetKernelStatus() + if st.ONNX.Enabled || st.ONNX.Reason == "" { + t.Fatalf("未配置时应 enabled=false 且有原因: %+v", st.ONNX) + } + }) + + t.Run("provider 存在但未加载", func(t *testing.T) { + a := &Agent{ + io: agentIO.NewIOManager(), + multimodalSpace: statusSpaceUnloaded{}, + embeddingProvider: "qwen3vl", + } + st := a.GetKernelStatus() + if st.ONNX.Enabled { + t.Fatal("Loaded() 为假时不能报 enabled") + } + if st.ONNX.Reason == "" { + t.Fatalf("应给出未加载的原因: %+v", st.ONNX) + } + }) +} + +// collectKernelStatus 的 knowledge 参数是**接口**类型,而 (*knowledge.Store)(nil) +// 塞进接口后 `ks != nil` 仍为真 → 调 List() 直接 panic。 +// 这条测试钉住这个成因:一旦不再 panic,说明参数形状变了, +// GetKernelStatus 里的 typed-nil 守卫就该同步删掉(否则它变成无意义代码)。 +func TestCollectKernelStatusTypedNilKnowledgePanics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("typed-nil 交给接口参数却未 panic:成因已变,请更新守卫与本测试") + } + }() + var nilStore *knowledge.Store + _ = collectKernelStatus(time.Now(), "a", "", 0, nil, nil, nil, nil, nilStore, nil, nil, nil, nil) +} diff --git a/internal/memory/vector/provider_adapter.go b/internal/memory/vector/provider_adapter.go index 5c0df5e..cd165e2 100644 --- a/internal/memory/vector/provider_adapter.go +++ b/internal/memory/vector/provider_adapter.go @@ -65,6 +65,18 @@ func (a *ProviderAdapter) embed(input embedding.Input) ([]float64, error) { func (a *ProviderAdapter) Fingerprint() string { return a.info.Fingerprint } func (a *ProviderAdapter) Dim() int { return a.info.Dimension } +// Modalities 报告该空间支持的输入模态(text/image/...)。 +// +// 模态是**可选能力**:MultimodalEmbedder 契约里没有它,状态查询按接口断言取用, +// 所以这里既不改公开接口,也不影响其它实现(核心也不硬编码任何模型名)。 +func (a *ProviderAdapter) Modalities() []string { + out := make([]string, 0, len(a.info.Modalities)) + for _, m := range a.info.Modalities { + out = append(out, string(m)) + } + return out +} + func (a *ProviderAdapter) Loaded() bool { a.mu.RLock() defer a.mu.RUnlock() diff --git a/internal/plugins/healthcheck/plugin.go b/internal/plugins/healthcheck/plugin.go index ff59e41..c249fb7 100644 --- a/internal/plugins/healthcheck/plugin.go +++ b/internal/plugins/healthcheck/plugin.go @@ -240,7 +240,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { p.selfToolNames["healthcheck_kernel"] = true s.RegisterTool("healthcheck_kernel", sdk.ToolDef{ Name: "healthcheck_kernel", - Description: "查询 Agent 内核运行状态快照,包括插件/工具/记忆/知识库/LLM Provider/运行时等各子系统信息。Agent 可通过此工具自主监测内核健康。", + Description: "查询 Agent 内核运行状态快照,包括**内核版本号与构建身份**(build.version / commit / build_time)、**统一多模态向量空间(ONNX 模型)是否启用**(onnx.enabled,未启用时给出原因)、插件/工具/记忆/知识库/LLM Provider/运行时等各子系统信息。Agent 可通过此工具自主监测内核健康。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{}, diff --git a/internal/sdk/status.go b/internal/sdk/status.go index 1a5b00e..4cb0cf1 100644 --- a/internal/sdk/status.go +++ b/internal/sdk/status.go @@ -38,9 +38,29 @@ type KernelStatus struct { Runtime RuntimeStatus `json:"runtime"` + // ONNX 报告统一多模态向量空间(ONNX 模型)是否**真的在用**。 + // + // 为何单列:内核的向量能力是三层降级(统一多模态空间 → 词嵌入 → TF-IDF), + // 只报「向量可用/不可用」分不清「ONNX 模型已加载」与「退回了纯文本路径」。 + // 模型缺失 / 运行时缺失 / provider 打开失败时这里是 enabled=false + reason。 + ONNX ONNXStatus `json:"onnx"` + Tracker TrackerStatus `json:"tracker"` } +// ONNXStatus 是统一多模态向量空间(ONNX 模型)的启用状态与身份。 +type ONNXStatus struct { + // Enabled 是 provider 真正打开且元数据合法(不是「配置里写了 provider」)。 + Enabled bool `json:"enabled"` + // Provider 是配置指定的 provider 名(如 chineseclip / qwen3vl / http)。 + Provider string `json:"provider,omitempty"` + Dim int `json:"dim,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Modalities []string `json:"modalities,omitempty"` + // Reason 是未启用时的原因(未配置 / 打开失败的具体错误 / 其它)。 + Reason string `json:"reason,omitempty"` +} + type PluginInfo struct { Name string `json:"name"` Loaded bool `json:"loaded"`