mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 01:48:11 +00:00
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:
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
|
||||
// 构造旧版 schema:relations 无复合唯一约束,且塞入重复行
|
||||
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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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=详情)
|
||||
|
||||
注意:
|
||||
- 有些工具有副作用(如写入数据),请使用安全参数,测试后应清理
|
||||
- 所有工具均为只读、无副作用,可放心调用
|
||||
- 尽可能覆盖所有工具
|
||||
- 每个工具只需测试一次
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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
108
internal/sdk/selftest.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user