mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58:06 +00:00
fix: LLM 工具循环 400、中断消息注入、ConPTY 终端支持
- agent: 工具轮请求尾部补 user 占位(zen 网关强制),tool 消息正确配对 - agent: 工具提醒/中断以 system 角色注入并带 [中断消息] 前缀,不进用户履历;系统提示词说明中断消息格式 - agentcli: 基于 ConPTY 的交互式终端(ptywin fork),terminal_create/read/write/resize/close/watch - webui: server 输出通道适配器(保留 reasoning_content/disable_thinking) - GUI: 沉浸式标题栏、icon 圆角重制、mascot 等打磨
This commit is contained in:
@ -183,6 +183,10 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.V
|
||||
docVec = vec.Vectorize(summary + " " + content)
|
||||
} else {
|
||||
docVec = s.veczer.Vectorize(summary + " " + content)
|
||||
}
|
||||
meta := map[string]string{"content_hash": contentHash}
|
||||
if source == "context_archived" {
|
||||
meta["is_archived_context"] = "true"
|
||||
}
|
||||
doc := &Doc{
|
||||
ID: id,
|
||||
@ -195,7 +199,7 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.V
|
||||
LastAccess: time.Now(),
|
||||
AccessCount: 1,
|
||||
Source: source,
|
||||
Meta: map[string]string{"content_hash": contentHash},
|
||||
Meta: meta,
|
||||
Vector: docVec,
|
||||
}
|
||||
s.docs[id] = doc
|
||||
|
||||
@ -193,11 +193,15 @@ func (d *Distiller) distillLoop() {
|
||||
|
||||
func (d *Distiller) distillOnce() {
|
||||
d.mu.Lock()
|
||||
cutoff := time.Now().AddDate(0, 0, -d.cfg.RetentionDays)
|
||||
batchSize := d.cfg.BatchSize
|
||||
if batchSize <= 0 {
|
||||
batchSize = 50
|
||||
}
|
||||
// 每 tick 取前 N 条未蒸馏记录(无 RetentionDays 门槛),蒸馏成功才标记/移除
|
||||
var toDistill []RawRecord
|
||||
var remaining []RawRecord
|
||||
for _, r := range d.records {
|
||||
if r.CreatedAt.Before(cutoff) && !r.Distilled {
|
||||
if !r.Distilled && len(toDistill) < batchSize {
|
||||
toDistill = append(toDistill, r)
|
||||
} else {
|
||||
remaining = append(remaining, r)
|
||||
@ -210,22 +214,29 @@ func (d *Distiller) distillOnce() {
|
||||
return
|
||||
}
|
||||
|
||||
batchSize := d.cfg.BatchSize
|
||||
if batchSize <= 0 {
|
||||
batchSize = 50
|
||||
}
|
||||
distilled := 0
|
||||
for i := 0; i < len(toDistill); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(toDistill) {
|
||||
end = len(toDistill)
|
||||
}
|
||||
d.distillBatch(toDistill[i:end])
|
||||
if d.distillBatch(toDistill[i:end]) {
|
||||
distilled += end - i
|
||||
} else {
|
||||
// 蒸馏失败:记录写回待处理队列,下次 tick 重试
|
||||
d.mu.Lock()
|
||||
d.records = append(toDistill[i:end], d.records...)
|
||||
d.mu.Unlock()
|
||||
}
|
||||
}
|
||||
d.cleanupRawFiles()
|
||||
log.Printf("[memory] distilled %d records", len(toDistill))
|
||||
if distilled > 0 {
|
||||
log.Printf("[memory] distilled %d records", distilled)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Distiller) distillBatch(batch []RawRecord) {
|
||||
// distillBatch 蒸馏一批记录,全部成功返回 true,任一失败返回 false(调用方重试)
|
||||
func (d *Distiller) distillBatch(batch []RawRecord) bool {
|
||||
var userContent, assistantContent string
|
||||
sessionIDs := make(map[string]bool)
|
||||
for _, r := range batch {
|
||||
@ -245,8 +256,10 @@ func (d *Distiller) distillBatch(batch []RawRecord) {
|
||||
}
|
||||
if _, _, err := d.db.Commit(triples, sessionID, 0); err != nil {
|
||||
log.Printf("[memory] distill commit: %v", err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *Distiller) cleanupRawFiles() {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@ -117,6 +118,71 @@ func TestDistillOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: 新记录无需等待 RetentionDays,下一 tick 立即蒸馏(文档所述 10min 频率)
|
||||
func TestDistillOnceFreshRecords(t *testing.T) {
|
||||
db, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
d := NewDistiller(db, dir, DistillerConfig{
|
||||
Interval: 10 * time.Minute,
|
||||
RetentionDays: 7,
|
||||
BatchSize: 50,
|
||||
})
|
||||
d.Append("sess1", "user", "我的名字是李四")
|
||||
d.Append("sess1", "assistant", "你好李四!")
|
||||
|
||||
if len(d.records) != 2 {
|
||||
t.Fatalf("expected 2 fresh records, got %d", len(d.records))
|
||||
}
|
||||
|
||||
d.distillOnce()
|
||||
if len(d.records) != 0 {
|
||||
t.Errorf("fresh records should be distilled on next tick (no retention gate), got %d remaining", len(d.records))
|
||||
}
|
||||
|
||||
// 二次蒸馏不重复(已蒸馏记录已被移除)
|
||||
d.distillOnce()
|
||||
if len(d.records) != 0 {
|
||||
t.Errorf("second distill should be no-op, got %d records", len(d.records))
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: BatchSize 限制每 tick 处理前 N 条,未蒸馏记录留待下个 tick
|
||||
func TestDistillOnceBatchLimit(t *testing.T) {
|
||||
db, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
d := NewDistiller(db, dir, DistillerConfig{
|
||||
Interval: 10 * time.Minute,
|
||||
RetentionDays: 7,
|
||||
BatchSize: 3,
|
||||
})
|
||||
for i := 0; i < 10; i++ {
|
||||
d.Append("sess1", "user", fmt.Sprintf("第 %d 条消息内容", i))
|
||||
}
|
||||
|
||||
d.distillOnce()
|
||||
if len(d.records) != 7 {
|
||||
t.Fatalf("expected 7 records remaining after batch 3, got %d", len(d.records))
|
||||
}
|
||||
|
||||
// 后续 tick 继续消化,最终全部蒸馏
|
||||
for i := 0; i < 5 && len(d.records) > 0; i++ {
|
||||
d.distillOnce()
|
||||
}
|
||||
if len(d.records) != 0 {
|
||||
t.Errorf("all records should be distilled after several ticks, got %d remaining", len(d.records))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractKeyTriples(t *testing.T) {
|
||||
tests := []struct {
|
||||
user string
|
||||
|
||||
@ -129,12 +129,13 @@ func ensureModelFile(modelPath string) {
|
||||
if modelPath == "" {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(modelPath); err == nil {
|
||||
path, _ := parseModelSpec(modelPath)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
}
|
||||
url := modelDownloadURL(modelPath)
|
||||
log.Printf("[static_embedder] model %s not found, downloading from fastText...", modelPath)
|
||||
if dlErr := downloadFastTextModel(modelPath, url); dlErr != nil {
|
||||
url := modelDownloadURL(path)
|
||||
log.Printf("[static_embedder] model %s not found, downloading from fastText...", path)
|
||||
if dlErr := downloadFastTextModel(path, url); dlErr != nil {
|
||||
log.Printf("[static_embedder] download failed: %v, will use TF-IDF fallback", dlErr)
|
||||
} else {
|
||||
log.Printf("[static_embedder] download ok")
|
||||
@ -183,7 +184,24 @@ func (e *StaticEmbedder) loadAll(paths []string) error {
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (e *StaticEmbedder) load(path string, primary bool) error {
|
||||
// parseModelSpec 解析模型路径规格:`path#top50000` 表示只加载前 50000 个词向量(按文件顺序,fastText
|
||||
// 词频降序,前 N 词覆盖绝大多数文本命中),用于降低常驻内存;无规格返回原路径与 0(全量加载)。
|
||||
func parseModelSpec(p string) (path string, topN int) {
|
||||
path = p
|
||||
if i := strings.IndexByte(p, '#'); i >= 0 {
|
||||
path = p[:i]
|
||||
spec := p[i+1:]
|
||||
if strings.HasPrefix(spec, "top") {
|
||||
if n, err := strconv.Atoi(strings.TrimPrefix(spec, "top")); err == nil && n > 0 {
|
||||
topN = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return path, topN
|
||||
}
|
||||
|
||||
func (e *StaticEmbedder) load(spec string, primary bool) error {
|
||||
path, topN := parseModelSpec(spec)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open: %w", err)
|
||||
@ -217,7 +235,11 @@ func (e *StaticEmbedder) load(path string, primary bool) error {
|
||||
vecSum = make([]float64, dim)
|
||||
}
|
||||
|
||||
loaded := 0
|
||||
for scanner.Scan() {
|
||||
if topN > 0 && loaded >= topN {
|
||||
break
|
||||
}
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
@ -244,6 +266,7 @@ func (e *StaticEmbedder) load(path string, primary bool) error {
|
||||
}
|
||||
count++
|
||||
}
|
||||
loaded++
|
||||
}
|
||||
|
||||
if primary {
|
||||
@ -263,7 +286,7 @@ func (e *StaticEmbedder) load(path string, primary bool) error {
|
||||
e.loaded = true
|
||||
}
|
||||
|
||||
log.Printf("[static_embedder] loaded %d words, dim=%d from %s", len(e.words), e.dim, path)
|
||||
log.Printf("[static_embedder] loaded %d words, dim=%d from %s (topN=%d)", len(e.words), e.dim, path, topN)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -86,3 +86,45 @@ func newSynthEmbedder(t testing.TB, dim int) *StaticEmbedder {
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Phase 5: #topN 规格裁剪加载——只加载前 N 个词向量,控制常驻内存
|
||||
func TestStaticEmbedderTopNSpec(t *testing.T) {
|
||||
path := writeSynthModel(t, 300)
|
||||
|
||||
// 解析规格
|
||||
cleanPath, topN := parseModelSpec(path + "#top5")
|
||||
if cleanPath != path || topN != 5 {
|
||||
t.Fatalf("parseModelSpec(#top5) = (%q, %d), want (%q, 5)", cleanPath, topN, path)
|
||||
}
|
||||
cleanPath2, topN2 := parseModelSpec(path)
|
||||
if cleanPath2 != path || topN2 != 0 {
|
||||
t.Fatalf("parseModelSpec(plain) = (%q, %d), want (%q, 0)", cleanPath2, topN2, path)
|
||||
}
|
||||
cleanPath3, topN3 := parseModelSpec(path + "#abc")
|
||||
if cleanPath3 != path || topN3 != 0 {
|
||||
t.Fatalf("parseModelSpec(#abc) = (%q, %d), want (%q, 0)", cleanPath3, topN3, path)
|
||||
}
|
||||
|
||||
// 裁剪加载
|
||||
e := NewStaticEmbedder(path + "#top5")
|
||||
if !e.Loaded() {
|
||||
t.Fatal("topN embedder should be loaded")
|
||||
}
|
||||
if len(e.words) != 5 {
|
||||
t.Errorf("expected 5 words loaded with #top5, got %d", len(e.words))
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: 裁剪后向量化仍可用(未命中词走 unkVec 兜底)
|
||||
func TestStaticEmbedderTopNVectorize(t *testing.T) {
|
||||
path := writeSynthModel(t, 300)
|
||||
e := NewStaticEmbedder(path + "#top1")
|
||||
if !e.Loaded() {
|
||||
t.Fatal("embedder should be loaded")
|
||||
}
|
||||
v := e.Vectorize("天气怎么样")
|
||||
// 未命中词不应产生空向量(unkVec 兜底)
|
||||
if len(v) == 0 {
|
||||
t.Error("vectorize with topN=1 should still produce a vector (unkVec fallback)")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user