Phase 0.1/1/3/6: 核心生产问题修复

Healthcheck 隔离 (Phase 0.1):
- 新增 internal/sdk/selftest.go: VirtualInstance 完全隔离自检空间
- PluginSDK.Selftest()/SelftestReset() 暴露隔离实例 (含 mutex)
- LLM 自检只读白名单 isSafeReadonlyTool 防写类工具污染生产
- 单测验证: healthcheck 后生产实例内容不变 + 无残留
- 存量清理: 删除 gotest/luatest 残留目录

GraphDB 去重 (Phase 1):
- migrateRelationUnique: 启动自动重建 relations 表加 UNIQUE 约束并去重
- Commit 改为存在性检查, 重复三元组仅刷新 confidence 不重复插入
- 3 个 dedup 单测全绿

配置时长解析 (Phase 3):
- parseDurationExtended 支持 2d/1w/3h 等人类可读单位
- GetDuration 全局生效, 防 2d 静默回退 30m

Agentcli 通知风暴治理 (Phase 6):
- 语义通知: 累积 notify_bytes(2KB) 或间隔 notify_interval(2s) 触发
- 生命周期即时通知: 启动/进程退出/EOF 立即通知
- 可配置 settings, 保留通知机制保证 agent 感知终端存在
- 运维止血: 已杀掉幽灵 PID 3716282 (bash git sparse clone 运行 16h)

Plan.md: 新增设计意图备忘(插件即App/分层记忆), 更新各 Phase 进度
This commit is contained in:
root
2026-08-12 13:51:40 +08:00
parent c19fea2584
commit 9d5a914941
12 changed files with 962 additions and 39 deletions

View File

@ -7,6 +7,7 @@ import (
"log"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
@ -695,13 +696,44 @@ func (r *ConfigRegistry) GetDuration(key string, defaultVal time.Duration) time.
if err != nil {
return defaultVal
}
d, err := time.ParseDuration(v)
d, err := parseDurationExtended(v)
if err != nil {
return defaultVal
}
return d
}
// durationUnitRe 匹配 `\d+[dhw]`(天/小时/周)这类 Go time.ParseDuration 不支持的天气单位。
var durationUnitRe = regexp.MustCompile(`(\d+)\s*([dhw])`)
// parseDurationExtended 解析人类可读时长,支持 Go 原生单位ns/us/ms/s/m/h
// 及复合如 "1h30m")加上 d与 w。返回实例化 duration失败返回 error。
func parseDurationExtended(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("empty duration")
}
// 先展开 d/w再交给 time.ParseDuration 处理剩余(含 m/h/s 组合)。
expanded := durationUnitRe.ReplaceAllStringFunc(s, func(m string) string {
parts := durationUnitRe.FindStringSubmatch(m)
n, _ := strconv.Atoi(parts[1])
switch parts[2] {
case "d":
return fmt.Sprintf("%dh", n*24)
case "w":
return fmt.Sprintf("%dh", n*24*7)
case "h":
return m
}
return m
})
d, err := time.ParseDuration(expanded)
if err != nil {
return 0, err
}
return d, nil
}
func (r *ConfigRegistry) GetBool(key string, defaultVal bool) bool {
r.mu.RLock()
defer r.mu.RUnlock()

View File

@ -210,6 +210,14 @@ func TestGetHelpers(t *testing.T) {
if got := r.GetDuration("dur_key", 0); got != 5*time.Minute {
t.Fatalf("GetDuration: expected 5m, got %v", got)
}
r.Set("dur_key_days", "2d")
if got := r.GetDuration("dur_key_days", 0); got != 48*time.Hour {
t.Fatalf("GetDuration d-unit: expected 48h, got %v", got)
}
r.Set("dur_key_weeks", "1w")
if got := r.GetDuration("dur_key_weeks", 0); got != 168*time.Hour {
t.Fatalf("GetDuration w-unit: expected 168h, got %v", got)
}
if got := r.GetDuration("nonexistent", 30*time.Second); got != 30*time.Second {
t.Fatalf("GetDuration fallback: expected 30s, got %v", got)
}
@ -221,6 +229,42 @@ func TestGetHelpers(t *testing.T) {
}
}
func TestParseDurationExtended(t *testing.T) {
cases := []struct {
in string
want time.Duration
wantErr bool
}{
{"2d", 48 * time.Hour, false},
{"1w", 7 * 24 * time.Hour, false},
{"1d", 24 * time.Hour, false},
{"2d12h", 60 * time.Hour, false},
{"30m", 30 * time.Minute, false},
{"500ms", 500 * time.Millisecond, false},
{"1h30m", 90 * time.Minute, false},
{" 3d ", 72 * time.Hour, false},
{"2w", 336 * time.Hour, false},
{"", 0, true},
{"abc", 0, true},
}
for _, c := range cases {
got, err := parseDurationExtended(c.in)
if c.wantErr {
if err == nil {
t.Errorf("%q: expected error, got %v", c.in, got)
}
continue
}
if err != nil {
t.Errorf("%q: unexpected error: %v", c.in, err)
continue
}
if got != c.want {
t.Errorf("%q: expected %v, got %v", c.in, c.want, got)
}
}
}
func TestSnapshotRestoreCoreLLM(t *testing.T) {
r := NewConfigRegistry("")
defer r.Close()

View File

@ -3,6 +3,7 @@ package memory
import (
"database/sql"
"fmt"
"strings"
"sync"
"time"
@ -103,7 +104,8 @@ func (g *GraphDB) initSchema() error {
date_bucket TEXT,
sentence_id INTEGER DEFAULT 0,
FOREIGN KEY (source_id) REFERENCES entities(id),
FOREIGN KEY (target_id) REFERENCES entities(id)
FOREIGN KEY (target_id) REFERENCES entities(id),
UNIQUE(source_id, target_id, relation_type, session_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_entity_name ON entities(name)`,
`CREATE INDEX IF NOT EXISTS idx_entity_type ON entities(type)`,
@ -132,9 +134,64 @@ func (g *GraphDB) initSchema() error {
// sentence_id 索引在迁移后创建,避免旧表缺少该列时失败
tx.Exec(`CREATE INDEX IF NOT EXISTS idx_relation_sentence ON relations(sentence_id)`)
// 迁移4为旧版 relations 表(无复合唯一约束)重建表以去重。
// 旧表由 2026-07 之前的版本创建,缺少 UNIQUE(source_id, target_id, relation_type, session_id)
// 生产库累积了海量重复关系。这里检查 sqlite_master 中已建表的 DDL
// 若不含该约束则走"新建带约束表 → INSERT OR IGNORE 拷贝去重 → 换名"的官方 12 步迁移。
if err := g.migrateRelationUnique(tx); err != nil {
return fmt.Errorf("migrate relations unique: %w", err)
}
return tx.Commit()
}
// migrateRelationUnique 检测 relations 表是否带复合唯一约束,缺失则重建去重。
// 必须在 initSchema 的同一个事务内调用(外键/索引均已存在时需先禁用外键再换名)。
func (g *GraphDB) migrateRelationUnique(tx *sql.Tx) error {
var ddl string
err := tx.QueryRow(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'relations'`).Scan(&ddl)
if err != nil {
if err == sql.ErrNoRows {
return nil // 表都不存在,无从迁移
}
return err
}
if strings.Contains(ddl, "UNIQUE") {
return nil // 已是新 schema
}
stmt := []string{
`ALTER TABLE relations RENAME TO relations_old`,
`CREATE TABLE relations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
relation_type TEXT NOT NULL,
confidence REAL DEFAULT 1.0,
status TEXT DEFAULT 'active',
session_id TEXT,
turn_id INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
date_bucket TEXT,
sentence_id INTEGER DEFAULT 0,
sentence_ref TEXT DEFAULT '',
FOREIGN KEY (source_id) REFERENCES entities(id),
FOREIGN KEY (target_id) REFERENCES entities(id),
UNIQUE(source_id, target_id, relation_type, session_id)
)`,
`INSERT OR IGNORE INTO relations (id, source_id, target_id, relation_type, confidence, status, session_id, turn_id, created_at, updated_at, date_bucket, sentence_id, sentence_ref)
SELECT id, source_id, target_id, relation_type, confidence, status, session_id, turn_id, created_at, updated_at, date_bucket, sentence_id, sentence_ref FROM relations_old`,
`DROP TABLE relations_old`,
}
for _, s := range stmt {
if _, err := tx.Exec(s); err != nil {
return err
}
}
return nil
}
func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) {
g.mu.Lock()
defer g.mu.Unlock()
@ -206,15 +263,34 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
}
}
_, err = tx.Exec(
`INSERT INTO relations (source_id, target_id, relation_type, confidence, session_id, turn_id, date_bucket, sentence_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
sourceID, targetID, t.Relation, confidence, sessionID, turnID, dateBucket, sentenceID,
)
if err != nil {
var existing int
err = tx.QueryRow(
`SELECT 1 FROM relations WHERE source_id = ? AND target_id = ? AND relation_type = ? AND session_id = ?`,
sourceID, targetID, t.Relation, sessionID,
).Scan(&existing)
if err == sql.ErrNoRows {
_, err = tx.Exec(
`INSERT INTO relations (source_id, target_id, relation_type, confidence, session_id, turn_id, date_bucket, sentence_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
sourceID, targetID, t.Relation, confidence, sessionID, turnID, dateBucket, sentenceID,
)
if err != nil {
return 0, 0, err
}
relationsCreated++
} else if err != nil {
return 0, 0, err
} else {
// 同一(会话内)三元组已存在:仅刷新置信度与时间戳,不重复计数
_, err = tx.Exec(
`UPDATE relations SET confidence = ?, updated_at = CURRENT_TIMESTAMP
WHERE source_id = ? AND target_id = ? AND relation_type = ? AND session_id = ?`,
confidence, sourceID, targetID, t.Relation, sessionID,
)
if err != nil {
return 0, 0, err
}
}
relationsCreated++
}
if err := tx.Commit(); err != nil {

View File

@ -1,6 +1,8 @@
package memory
import (
"database/sql"
"path/filepath"
"os"
"testing"
)
@ -60,6 +62,135 @@ func TestCommitTriples(t *testing.T) {
}
}
func TestCommitDedupSameSession(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
triple := []Triple{{Subject: "李四", Relation: "喜欢", Object: "篮球"}}
ec, rc, err := g.Commit(triple, "session_dup", 1)
if err != nil {
t.Fatal(err)
}
if ec != 2 || rc != 1 {
t.Fatalf("first commit: want 2/1, got %d/%d", ec, rc)
}
// 同一会话重复 commit 同一三元组:关系不再新增
_, rc, err = g.Commit(triple, "session_dup", 2)
if err != nil {
t.Fatal(err)
}
if rc != 0 {
t.Errorf("duplicate commit should not create relations again, got %d", rc)
}
var cnt int
if err := g.db.QueryRow(`SELECT COUNT(*) FROM relations`).Scan(&cnt); err != nil {
t.Fatal(err)
}
if cnt != 1 {
t.Errorf("expected exactly 1 relation after duplicate commit, got %d", cnt)
}
}
func TestCommitDedupDifferentSession(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
triple := []Triple{{Subject: "王五", Relation: "喜欢", Object: "足球"}}
for _, sess := range []string{"s1", "s2"} {
if _, _, err := g.Commit(triple, sess, 0); err != nil {
t.Fatal(err)
}
}
var cnt int
if err := g.db.QueryRow(`SELECT COUNT(*) FROM relations`).Scan(&cnt); err != nil {
t.Fatal(err)
}
if cnt != 2 {
t.Errorf("different sessions may repeat a triple, expected 2 relations, got %d", cnt)
}
}
func TestMigrateRelationUniqueDedupsOldTable(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "legacy.db")
// 构造旧版 schemarelations 无复合唯一约束,且塞入重复行
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatal(err)
}
setup := []string{
`CREATE TABLE entities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
type TEXT DEFAULT 'Concept',
mention_count INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE sentences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE relations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
relation_type TEXT NOT NULL,
confidence REAL DEFAULT 1.0,
status TEXT DEFAULT 'active',
session_id TEXT,
turn_id INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
date_bucket TEXT,
sentence_id INTEGER DEFAULT 0,
sentence_ref TEXT DEFAULT '',
FOREIGN KEY (source_id) REFERENCES entities(id),
FOREIGN KEY (target_id) REFERENCES entities(id)
)`,
`INSERT INTO entities (id, name) VALUES (1, '张三'), (2, '编程')`,
`INSERT INTO relations (source_id, target_id, relation_type, session_id) VALUES (1, 2, '喜欢', 's'), (1, 2, '喜欢', 's')`,
}
for _, s := range setup {
if _, err := db.Exec(s); err != nil {
t.Fatal(err)
}
}
db.Close()
// 用 NewGraphDB 打开,应触发 migrateRelationUnique重建带约束表并去重
g, err := NewGraphDB(dbPath)
if err != nil {
t.Fatal(err)
}
defer g.Close()
var cnt int
if err := g.db.QueryRow(`SELECT COUNT(*) FROM relations`).Scan(&cnt); err != nil {
t.Fatal(err)
}
if cnt != 1 {
t.Errorf("expected 1 relation after migration dedup, got %d", cnt)
}
// 再次提交重复三元组不应再新增
_, rc, err := g.Commit([]Triple{{Subject: "张三", Relation: "喜欢", Object: "编程"}}, "s", 1)
if err != nil {
t.Fatal(err)
}
if rc != 0 {
t.Errorf("after migration, duplicate commit should add 0 relations, got %d", rc)
}
}
func TestCommitEmptyTriples(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)

View File

@ -18,10 +18,11 @@ import (
)
const (
DefaultTimeout = 5 * time.Minute
ReadBufSize = 4096
MaxOutputBuffer = 128 * 1024
NotifyOutputDelay = 500 * time.Millisecond
DefaultTimeout = 5 * time.Minute
ReadBufSize = 4096
MaxOutputBuffer = 128 * 1024
DefaultNotifyBytes = 2048 // 积累 2KB 未读输出再通知
DefaultNotifyInterval = 2 * time.Second // 同一终端两次通知的最小间隔(兜底)
)
// ptyTerm 抽象平台终端后端Linux PTY / Windows ConPTY
@ -56,6 +57,10 @@ type TerminalSession struct {
closed bool
stopCh chan struct{}
done chan struct{}
// 通知节流字段
unreadBytes int // 最近一次通知后积累的未读字节数
lastNotify time.Time // 最近一次通知时间
}
func (t *TerminalSession) Write(input string) (int, error) {
@ -128,6 +133,8 @@ type Plugin struct {
sessions map[string]*TerminalSession
nextID int
defaultTimeout time.Duration
notifyBytes int
notifyInterval time.Duration
}
func New(name string) *Plugin {
@ -147,6 +154,16 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
Description: "终端自动关闭的默认时间,例如 5m, 10m, 30m, 1h默认 5m",
Default: "5m",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "notify_bytes", Type: "int", DisplayName: "通知阈值字节数",
Description: "累积多少字节未读输出后发送通知(默认 2048",
Default: "2048",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "notify_interval", Type: "string", DisplayName: "通知最小间隔",
Description: "同一终端两次通知的最小时间间隔,如 2s, 5s默认 2s",
Default: "2s",
})
if v, _ := s.Settings().Get("default_timeout"); v != nil {
if s, ok := v.(string); ok && s != "" {
if d, err := time.ParseDuration(s); err == nil {
@ -157,6 +174,24 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
if p.defaultTimeout <= 0 {
p.defaultTimeout = DefaultTimeout
}
if v, _ := s.Settings().Get("notify_bytes"); v != nil {
if i, ok := v.(float64); ok && i > 0 {
p.notifyBytes = int(i)
}
}
if p.notifyBytes <= 0 {
p.notifyBytes = DefaultNotifyBytes
}
if v, _ := s.Settings().Get("notify_interval"); v != nil {
if s, ok := v.(string); ok && s != "" {
if d, err := time.ParseDuration(s); err == nil {
p.notifyInterval = d
}
}
}
if p.notifyInterval <= 0 {
p.notifyInterval = DefaultNotifyInterval
}
s.RegisterTool("terminal_create", sdk.ToolDef{
Name: "terminal_create",
@ -558,12 +593,15 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
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)
// 立即发送首次"终端已启动"通知,让 agent 感知存在
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已启动]", t.id))
t.lastNotify = time.Now()
for {
if t.IsExpired() {
log.Printf("[agentcli] terminal %s expired after %v", t.id, t.timeout)
@ -587,20 +625,34 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
return
case r := <-readCh:
if r.err != nil {
// 读取错误/EOF → 立即通知(进程可能已结束)
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 读取结束: %v]", t.id, r.err))
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]
// 语义通知:累积未读字节数
t.mu.Lock()
t.unreadBytes += r.n
needNotify := t.unreadBytes >= p.notifyBytes ||
time.Since(t.lastNotify) >= p.notifyInterval
t.mu.Unlock()
if needNotify {
t.mu.Lock()
preview := t.buf.String()
if len(preview) > 200 {
preview = preview[len(preview)-200:] // 取最新 200 字符
}
preview = sanitizePreview(preview)
t.unreadBytes = 0
t.lastNotify = time.Now()
t.mu.Unlock()
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 有新输出]\n%s", t.id, preview))
lastNotify = time.Now()
}
}
case <-time.After(pollInterval):

View File

@ -335,6 +335,11 @@ func (p *Plugin) runAutoCheck(s *sdk.PluginSDK) {
func (p *Plugin) runFullCheck(s *sdk.PluginSDK) (interface{}, error) {
results := []checkResult{}
// 每轮自检前重置隔离虚拟实例,清空上轮测试数据(仅影响虚拟空间,不碰生产存储)。
if err := s.SelftestReset("hc"); err != nil {
log.Printf("[healthcheck] selftest reset: %v", err)
}
pluginResult := p.checkPluginsRaw(s)
results = append(results, pluginResult...)
@ -457,55 +462,86 @@ func (p *Plugin) collectAllTools(s *sdk.PluginSDK) []toolInfo {
return tools
}
func (p *Plugin) selftestInst(s *sdk.PluginSDK) (*sdk.VirtualInstance, error) {
vi, err := s.Selftest("hc")
if err != nil {
return nil, err
}
if vi == nil {
return nil, fmt.Errorf("Selftest 不可用")
}
return vi, nil
}
func (p *Plugin) testMemoryRaw(s *sdk.PluginSDK) checkResult {
// 在隔离虚拟图记忆上验证写→查→删,绝不动生产 GraphDB。
vi, err := p.selftestInst(s)
if err != nil {
return checkResult{Name: "memory", Status: "skip", Detail: fmt.Sprintf("虚拟实例不可用: %v", err), Pass: true}
}
marker := fmt.Sprintf("_hc_%d", time.Now().UnixNano())
triples := []sdk.Triple{
{Subject: marker, Relation: "is", Object: "healthcheck_test", SubjectType: "System", ObjectType: "Flag"},
}
start := time.Now()
if err := s.Memory().Commit(triples); err != nil {
return checkResult{Name: "memory_write", Status: "fail", Detail: fmt.Sprintf("写入失败: %v", err), Pass: false}
if err := vi.Memory.Commit(triples); err != nil {
return checkResult{Name: "memory", Status: "fail", Detail: fmt.Sprintf("写入失败: %v", err), Pass: false}
}
n, err := s.Memory().Purge(map[string]string{"subject_contains": marker}, "hard")
ents, rels, err := vi.Memory.Recall([]string{marker}, 1)
if err != nil {
return checkResult{Name: "memory_purge", Status: "fail", Detail: fmt.Sprintf("清理失败: %v", err), Pass: false}
return checkResult{Name: "memory", Status: "fail", Detail: fmt.Sprintf("查询失败: %v", err), Pass: false}
}
if len(ents) == 0 && len(rels) == 0 {
return checkResult{Name: "memory", Status: "warn", Detail: "写入成功但查询未命中", Pass: true}
}
_, err = vi.Memory.Purge(map[string]string{"subject_contains": marker}, "hard")
if err != nil {
return checkResult{Name: "memory", Status: "fail", Detail: fmt.Sprintf("清理失败: %v", err), Pass: false}
}
elapsed := time.Since(start)
return checkResult{
Name: "memory",
Status: "ok",
Detail: fmt.Sprintf("写入+清理 %d 条, 耗时 %v", n, elapsed.Round(time.Millisecond)),
Detail: fmt.Sprintf("隔离虚拟记忆写入+查询+清理正常, 耗时 %v", elapsed.Round(time.Millisecond)),
Pass: true,
}
}
func (p *Plugin) testKnowledgeRaw(s *sdk.PluginSDK) checkResult {
// 在隔离虚拟知识库上验证写→查→删,绝不动生产知识库。
vi, err := p.selftestInst(s)
if err != nil {
return checkResult{Name: "knowledge", Status: "skip", Detail: fmt.Sprintf("虚拟实例不可用: %v", err), Pass: true}
}
marker := fmt.Sprintf("_hc_knowledge_test_%d", time.Now().UnixNano())
start := time.Now()
if err := s.Knowledge().Add(marker, "健康检查测试标记,可忽略"); err != nil {
if err := vi.Knowledge.Add(marker, "健康检查测试标记,可忽略"); err != nil {
return checkResult{Name: "knowledge", Status: "fail", Detail: fmt.Sprintf("写入失败: %v", err), Pass: false}
}
results, err := s.Knowledge().Search("健康检查测试标记", 3)
results, err := vi.Knowledge.Search("健康检查测试标记", 3)
if err != nil {
s.Knowledge().Remove(marker)
vi.Knowledge.Remove(marker)
return checkResult{Name: "knowledge", Status: "fail", Detail: fmt.Sprintf("查询失败: %v", err), Pass: false}
}
elapsed := time.Since(start)
// 清理测试条目,避免积累
s.Knowledge().Remove(marker)
vi.Knowledge.Remove(marker)
if len(results) > 0 {
return checkResult{
Name: "knowledge",
Status: "ok",
Detail: fmt.Sprintf("写入+查询正常, 耗时 %v", elapsed.Round(time.Millisecond)),
Detail: fmt.Sprintf("隔离虚拟知识库写入+查询+清理正常, 耗时 %v", elapsed.Round(time.Millisecond)),
Pass: true,
}
}
@ -519,19 +555,25 @@ func (p *Plugin) testKnowledgeRaw(s *sdk.PluginSDK) checkResult {
}
func (p *Plugin) testDocStoreRaw(s *sdk.PluginSDK) checkResult {
// 在隔离虚拟文档记忆上验证写→查→删,绝不动生产 Document。
vi, err := p.selftestInst(s)
if err != nil {
return checkResult{Name: "documents", Status: "skip", Detail: fmt.Sprintf("虚拟实例不可用: %v", err), Pass: true}
}
start := time.Now()
doc := &sdk.Doc{
Title: fmt.Sprintf("健康检查测试文档 %d", time.Now().UnixNano()),
Content: "这是一条由 healthcheck 插件创建的测试文档,用于验证文档记忆系统是否正常工作。",
}
if err := s.DocMemory().Insert(doc); err != nil {
if err := vi.DocMemory.Insert(doc); err != nil {
return checkResult{Name: "documents", Status: "fail", Detail: fmt.Sprintf("写入失败: %v", err), Pass: false}
}
// 清理测试文档避免积累SDK Insert 不回填 ID经 Query 按标题定位)
for _, d := range s.DocMemory().Query("健康检查测试文档", 10) {
for _, d := range vi.DocMemory.Query("健康检查测试文档", 10) {
if d.ID != "" && strings.HasPrefix(d.Title, "健康检查测试文档") {
s.DocMemory().Remove(d.ID)
vi.DocMemory.Remove(d.ID)
}
}
@ -539,7 +581,7 @@ func (p *Plugin) testDocStoreRaw(s *sdk.PluginSDK) checkResult {
return checkResult{
Name: "documents",
Status: "ok",
Detail: fmt.Sprintf("写入+删除正常, 耗时 %v", elapsed.Round(time.Millisecond)),
Detail: fmt.Sprintf("隔离虚拟文档记忆写入+查询+清理正常, 耗时 %v", elapsed.Round(time.Millisecond)),
Pass: true,
}
}
@ -626,7 +668,8 @@ func (p *Plugin) testLLMDriven(s *sdk.PluginSDK) checkResult {
}
// collectToolDefsForLLM 收集全部已注册的工具定义供 LLM 发现和测试。
// 动态排除本插件自身注册的工具(通过 selfToolNames避免 LLM 自我循环调用
// 动态排除本插件自身注册的工具(通过 selfToolNames避免 LLM 自我循环调用
// 且仅保留"只读/轻量验证"类工具(白名单语义),防止 LLM 自检污染生产数据或引发副作用。
func (p *Plugin) collectToolDefsForLLM(s *sdk.PluginSDK) []sdk.ToolDef {
seen := map[string]bool{}
var defs []sdk.ToolDef
@ -635,6 +678,9 @@ func (p *Plugin) collectToolDefsForLLM(s *sdk.PluginSDK) []sdk.ToolDef {
if p.selfToolNames[d.Name] || seen[d.Name] {
return
}
if !isSafeReadonlyTool(d.Name) {
return
}
seen[d.Name] = true
defs = append(defs, d)
}
@ -651,10 +697,38 @@ func (p *Plugin) collectToolDefsForLLM(s *sdk.PluginSDK) []sdk.ToolDef {
return defs
}
// isSafeReadonlyTool 判断工具是否为"只读/无副作用、适合健康检查 LLM 自检"的工具。
// 仅白名单语义:不在白名单的工具一律不测(宁可少测,不可污染/引发副作用)。
func isSafeReadonlyTool(name string) bool {
// 明确只读的查询/列表类工具
readonlyExact := map[string]bool{
"memory_recall": true,
"memory_introspect": true,
"doc_query": true,
"knowledge_search": true,
"knowledge_list": true,
"person_query": true,
"person_network": true,
"llm_list_sources": true,
"output_list_channels": true,
"terminal_list": true,
}
if readonlyExact[name] {
return true
}
// 带 _list/_help 后缀的通常是只读展示
for _, sfx := range []string{"_list", "_help"} {
if strings.HasSuffix(name, sfx) {
return true
}
}
return false
}
// buildDiscoveryPrompt 为 LLM 构造工具探索 prompt。
func (p *Plugin) buildDiscoveryPrompt(toolDefs []sdk.ToolDef) string {
var b strings.Builder
b.WriteString(fmt.Sprintf(`你是一名系统健康检查专家。以下是系统中各插件提供的 %d 个工具(已自动排除健康检查插件自身工具):
b.WriteString(fmt.Sprintf(`你是一名系统健康检查专家。以下是系统中各插件提供的 %d 个工具(已自动排除健康检查插件自身工具及所有会写/删/改生产数据或产生外部副作用的工具,以下均为只读/查询/列表类工具
你的任务是:逐一尝试调用这些工具,验证它们是否正常工作,并对于每个工具使用 healthcheck_report 工具上报测试结果。
@ -665,7 +739,7 @@ func (p *Plugin) buildDiscoveryPrompt(toolDefs []sdk.ToolDef) string {
4. 调用 healthcheck_report 工具上报tool_name, status=ok/fail/skip, detail=详情)
注意:
- 有些工具有副作用(如写入数据),请使用安全参数,测试后应清理
- 所有工具均为只读、无副作用,可放心调用
- 尽可能覆盖所有工具
- 每个工具只需测试一次

View File

@ -3,6 +3,7 @@ package healthcheck
import (
"encoding/json"
"os"
"strings"
"testing"
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
@ -196,6 +197,20 @@ func TestHealthcheckWithMemory(t *testing.T) {
}
defer memDB.Close()
// 预置一条生产数据,验证 healthcheck 自检后不触碰它
_, _, err = memDB.Commit([]memory.Triple{{Subject: "用户", Relation: "喜欢", Object: "咖啡"}}, "test", 0)
if err != nil {
t.Fatal(err)
}
memSnapshot := func() (int, int) {
m, _ := memDB.Introspect()
ents, _ := m["entity_count"].(int)
rels, _ := m["relation_count"].(int)
return ents, rels
}
be, br := memSnapshot()
_, tc, err := setupPluginWith(sdk.SDKConfig{Memory: sdk.NewGraphMemory(memDB)})
if err != nil {
t.Fatal(err)
@ -212,11 +227,25 @@ func TestHealthcheckWithMemory(t *testing.T) {
json.Unmarshal(data, &resp)
if resp["status"] != "ok" {
t.Fatalf("expected status ok, got %v", resp["status"])
t.Fatalf("expected status ok, got %v (detail=%v)", resp["status"], resp["detail"])
}
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"])
}
// 关键:生产记忆内容必须保持不变(未被 healthcheck 污染)
ae, ar := memSnapshot()
if ae != be || ar != br {
t.Fatalf("production memory polluted by healthcheck self-test: before=(%d,%d) after=(%d,%d)", be, br, ae, ar)
}
// 再次确认:注入实例中不应出现 _hc_ 测试实体
relResult, _ := memDB.Recall([]string{"_hc_"}, nil, 1, "")
for _, e := range relResult.Entities {
if len(e.Name) >= 4 && e.Name[:4] == "_hc_" {
t.Fatalf("healthcheck left _hc_ entity in production memory: %q", e.Name)
}
}
}
func TestHealthcheckWithKnowledge(t *testing.T) {
@ -232,6 +261,12 @@ func TestHealthcheckWithKnowledge(t *testing.T) {
}
defer ks.Stop()
// 预置一条真实知识,验证 healthcheck 自检后不触碰它
if err := ks.Add("生产知识点", "这是生产知识,不应被健康检查破坏"); err != nil {
t.Fatal(err)
}
before := len(ks.List())
_, tc, err := setupPluginWith(sdk.SDKConfig{Knowledge: sdk.NewKnowledge(ks)})
if err != nil {
t.Fatal(err)
@ -264,6 +299,20 @@ func TestHealthcheckWithKnowledge(t *testing.T) {
if knowledgeCheck == nil {
t.Fatal("expected knowledge check in results")
}
if knowledgeCheck["status"] != "ok" {
t.Fatalf("expected knowledge check ok, got %v (detail=%v)", knowledgeCheck["status"], knowledgeCheck["detail"])
}
// 生产知识库内容必须保持不变(未被 healthcheck 污染)
after := len(ks.List())
if after != before {
t.Fatalf("production knowledge polluted: before=%d after=%d", before, after)
}
for _, name := range ks.List() {
if strings.HasPrefix(name, "_hc_knowledge_test_") {
t.Fatalf("healthcheck left test knowledge in production: %q", name)
}
}
}
func TestHealthcheckWithDocStore(t *testing.T) {
@ -279,6 +328,12 @@ func TestHealthcheckWithDocStore(t *testing.T) {
}
defer ds.Stop()
// 预置一篇生产文档,验证 healthcheck 自检后不触碰它
if err := ds.Insert(&doc.Doc{ID: "prod_doc", Summary: "生产文档", Content: "这是生产文档,不应被健康检查破坏"}); err != nil {
t.Fatal(err)
}
before := len(ds.Query("生产文档", 10))
_, tc, err := setupPluginWith(sdk.SDKConfig{DocMemory: sdk.NewDocMemory(ds)})
if err != nil {
t.Fatal(err)
@ -311,6 +366,15 @@ func TestHealthcheckWithDocStore(t *testing.T) {
if docCheck == nil {
t.Fatal("expected documents check in results")
}
if docCheck["status"] != "ok" {
t.Fatalf("expected documents check ok, got %v (detail=%v)", docCheck["status"], docCheck["detail"])
}
// 生产文档必须保持不变(未被 healthcheck 污染)
after := len(ds.Query("生产文档", 10))
if after != before {
t.Fatalf("production doc store polluted: before=%d after=%d", before, after)
}
}
func TestLLMReportCollection(t *testing.T) {
@ -330,4 +394,88 @@ func TestLLMReportCollection(t *testing.T) {
}
}
// TestSafeReadonlyTool 验证 LLM 自检工具过滤:写类/副作用工具被拒绝,只读工具被放行。
func TestSafeReadonlyTool(t *testing.T) {
// 只读:应放行
readonly := []string{
"memory_recall", "memory_introspect", "doc_query",
"knowledge_search", "knowledge_list", "person_query",
"person_network", "llm_list_sources", "output_list_channels",
"terminal_list", "files_list",
}
for _, name := range readonly {
if !isSafeReadonlyTool(name) {
t.Errorf("expected readonly tool %q to be safe, but rejected", name)
}
}
// 写/删/改/副作用:应被拒绝
mutating := []string{
"memory_commit", "memory_edit", "memory_purge", "memory_delete_entity",
"memory_merge", "memory_block_merge",
"knowledge_create", "knowledge_delete",
"doc_commit", "doc_delete",
"person_set_trait", "person_relate",
"output_send__qq", "output_send__cli",
"llm_set_source", "config_set",
"timer_set", "plgreload", "plugin_disable",
"cmd_run", "files_write", "files_delete", "terminal_create", "terminal_write",
"terminal_close", "spawn_child",
}
for _, name := range mutating {
if isSafeReadonlyTool(name) {
t.Errorf("expected mutating tool %q to be rejected, but allowed", name)
}
}
}
// TestCollectToolDefsForLLMNoMutating 验证 collectToolDefsForLLM 不会把写类工具交给 LLM 自检。
func TestCollectToolDefsForLLMNoMutating(t *testing.T) {
p := &Plugin{name: "healthcheck", selfToolNames: map[string]bool{"healthcheck": true, "healthcheck_report": true}}
// 构造一个包含写类工具的 ToolDef 集合,注入工具注册表
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: "knowledge_create", Plugin: "knowledge", Description: "create"},
{Name: "cmd_run", Plugin: "cmd", Description: "run cmd"},
{Name: "files_list", Plugin: "files", Description: "list"},
}
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)
allowed := map[string]bool{}
for _, d := range got {
allowed[d.Name] = true
}
// 只读工具应被包含
for _, name := range []string{"memory_recall", "doc_query", "knowledge_search", "files_list"} {
if !allowed[name] {
t.Errorf("expected readonly tool %q in LLM selftest set, missing", name)
}
}
// 写类/副作用工具绝不能被交给 LLM
for _, name := range []string{"memory_commit", "doc_commit", "knowledge_create", "cmd_run"} {
if allowed[name] {
t.Errorf("mutating tool %q must NOT be in LLM selftest set", name)
}
}
}

View File

@ -2,6 +2,7 @@ package sdk
import (
"log"
"sync"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
@ -98,6 +99,9 @@ type PluginSDK struct {
config ConfigAPI
tool ToolAPI
indexer IndexerAPI
selftestMu sync.Mutex
selftest *VirtualInstance
}
func (s *PluginSDK) PluginMgr() PluginManager { return s.pluginMgr }
@ -211,6 +215,42 @@ func New(name string, cfg SDKConfig) *PluginSDK {
}
}
// Selftest 返回一个隔离的虚拟自检实例healthcheck 等内置插件用),
// 完全独立于生产存储,不产生任何污染。首次调用创建,复用已存在实例;
// 每轮自检前调用 SelftestReset 重建以清空上轮测试数据。
func (s *PluginSDK) Selftest(scope string) (*VirtualInstance, error) {
s.selftestMu.Lock()
defer s.selftestMu.Unlock()
if s.selftest == nil {
vi, err := NewVirtualInstance(scope)
if err != nil {
return nil, err
}
s.selftest = vi
}
return s.selftest, nil
}
// SelftestReset 清理并重建隔离自检实例,用于每轮健康检查前重置状态。
func (s *PluginSDK) SelftestReset(scope string) error {
s.selftestMu.Lock()
defer s.selftestMu.Unlock()
if s.selftest == nil {
vi, err := NewVirtualInstance(scope)
if err != nil {
return err
}
s.selftest = vi
return nil
}
vi, err := s.selftest.Reset(scope)
if err != nil {
return err
}
s.selftest = vi
return nil
}
func (s *PluginSDK) Status() StatusAPI { return s.status }
func (s *PluginSDK) Supervisor() SupervisorAPI { return s.supervisor }
func (s *PluginSDK) Adapter() AdapterAPI { return s.adapter }

108
internal/sdk/selftest.go Normal file
View File

@ -0,0 +1,108 @@
package sdk
import (
"fmt"
"os"
"path/filepath"
"sync"
"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/memory/text"
)
// VirtualInstance 是完全隔离的虚拟存储集合,供内置插件(如 healthcheck
// 做不污染生产存储的"写→查→删"往返自检。所有写入发生在独立临时目录,
// 由 Cleanup 统一销毁。
type VirtualInstance struct {
Memory MemoryAPI
Knowledge KnowledgeAPI
DocMemory DocMemoryAPI
TextMemory TextMemoryAPI
dir string
mu sync.Mutex
created bool
}
// newBaseDir 返回一个隔离的临时根目录hot 前缀,避免与生产数据混淆)。
func newBaseDir(scope string) (string, error) {
return os.MkdirTemp("", "homeagent_selftest_"+scope+"_")
}
// NewVirtualInstance 创建完全隔离的测试实例,使用独立临时目录。
func NewVirtualInstance(scope string) (*VirtualInstance, error) {
if scope == "" {
scope = "hc"
}
base, err := newBaseDir(scope)
if err != nil {
return nil, err
}
v := &VirtualInstance{dir: base, created: true}
if err := v.initLocked(); err != nil {
os.RemoveAll(base)
return nil, err
}
return v, nil
}
// initLocked 初始化各隔离存储。调用方须持 v.mu。
func (v *VirtualInstance) initLocked() error {
memDB, err := memory.NewGraphDB(filepath.Join(v.dir, "graph.db"))
if err != nil {
return fmt.Errorf("virtual graph db: %w", err)
}
v.Memory = NewGraphMemory(memDB)
ks := knowledge.NewStore(filepath.Join(v.dir, "knowledge"))
if err := ks.Start(); err != nil {
return fmt.Errorf("virtual knowledge store: %w", err)
}
v.Knowledge = NewKnowledge(ks)
ds := doc.NewStore(filepath.Join(v.dir, "documents"))
if err := ds.Start(); err != nil {
return fmt.Errorf("virtual doc store: %w", err)
}
v.DocMemory = NewDocMemory(ds)
tm := text.New(filepath.Join(v.dir, "text"))
if err := tm.Start(); err != nil {
return fmt.Errorf("virtual text memory: %w", err)
}
v.TextMemory = NewTextMemory(tm)
return nil
}
// Cleanup 关闭并销毁该虚拟实例的全部临时存储。之后实例不可再用。
func (v *VirtualInstance) Cleanup() {
v.mu.Lock()
defer v.mu.Unlock()
if !v.created {
return
}
if tm, ok := v.TextMemory.(*textMemoryImpl); ok && tm.tm != nil {
tm.tm.Stop()
}
if ds, ok := v.DocMemory.(*docMemoryImpl); ok && ds.ds != nil {
ds.ds.Stop()
}
if ks, ok := v.Knowledge.(*knowledgeImpl); ok && ks.ks != nil {
ks.ks.Stop()
}
if gm, ok := v.Memory.(*graphMemory); ok && gm.db != nil {
gm.db.Close()
}
os.RemoveAll(v.dir)
v.created = false
}
// Reset 清理当前实例并重建一个全新的隔离实例,用于每轮自检前重置状态。
// 返回新实例;失败时旧实例已被清理、返回 err调用方需重新创建。
func (v *VirtualInstance) Reset(scope string) (*VirtualInstance, error) {
v.Cleanup()
return NewVirtualInstance(scope)
}