fix: correct context pruning order and vector alignment

- Prune context BEFORE processing (LSTM forget gate pattern)
  so LLM only sees relevant context, instead of pruning after the fact
- Fix ensureTrained() to recompute all event vectors after retraining
  vectorizer, fixing feature-space mismatch between stored vectors and
  query vector that made relevance scoring effectively random
- Add read lock to knowledge BuildTree() (data race fix)
- Log writeIndex() errors instead of discarding them
- Fix TOCTOU race in document ContextToDoc() dedup
- Fix healthcheck timing (measure elapsed before cleanup)
- Refactor waiter CLI into separate files (state, conn, config, editor,
  history, builtin) for maintainability
- Add CLI plugin API key authentication
This commit is contained in:
root
2026-07-07 17:07:38 +08:00
parent f794513b39
commit 2edc039351
13 changed files with 1139 additions and 602 deletions

View File

@ -2,6 +2,7 @@ package core
import (
"context"
"errors"
"fmt"
"log"
"strings"
@ -251,16 +252,27 @@ func (a *Agent) interceptLoop() {
a.llmMu.Unlock()
if hasActiveLLM {
select {
case a.interceptCh <- clone:
default:
log.Printf("[agent] intercept channel full, queuing input for %s", evt.Source)
// 后台整理任务被打断中断消息重新注入为独立输入consolidation 的 process 不会路由回复)
if a.currentOutputChannel == "_consolidation_" {
log.Printf("[agent] consolidation interrupted, re-injecting input for %s/%s", evt.Source, evt.OutputChannel)
a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
"content": text,
"interrupt": true,
"interrupt_source": evt.Source,
"interrupt_channel": evt.OutputChannel,
})
} else {
select {
case a.interceptCh <- clone:
default:
log.Printf("[agent] intercept channel full, queuing input for %s", evt.Source)
a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
"content": text,
"interrupt": true,
"interrupt_source": evt.Source,
"interrupt_channel": evt.OutputChannel,
})
}
}
} else {
a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
@ -325,6 +337,12 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
blocks, fallback := a.mediaToBlocks(evt.Payload, evt.Type, evt.Source)
// 先遗忘再输入
archived := a.context.Prune(fallback, a.maxContextSize-1, a.docStore)
if archived > 0 {
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
}
a.context.Append(ContextEvent{
Timestamp: start,
Source: evt.Source,
@ -371,11 +389,6 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
ToolsUsed: toolsUsed,
})
archived := a.context.Prune(response, a.maxContextSize, a.docStore)
if archived > 0 {
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
}
a.emitResponse(evt, response)
}
@ -466,6 +479,12 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
input = stageCtx.RawMessage
// 先"遗忘"再输入用当前输入决定淘汰哪些不相关旧事件LSTM forget gate 模式)
archived := a.context.Prune(input, a.maxContextSize-1, a.docStore)
if archived > 0 {
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
}
a.context.Append(ContextEvent{
Timestamp: start,
Source: evt.Source,
@ -492,12 +511,6 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
ToolsUsed: toolsUsed,
})
// 基于相关性裁剪上下文:保留与当前输入最相关的 maxContextSize 条
archived := a.context.Prune(response, a.maxContextSize, a.docStore)
if archived > 0 {
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
}
a.emitResponse(evt, response)
if !stageCtx.NoMemory {
@ -661,6 +674,14 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
}
if llmErr != nil {
if errors.Is(llmErr, context.Canceled) && a.ctx.Err() == nil {
// 后台整理任务被打断:中断已重新注入为独立输入,直接返回
if a.currentOutputChannel == "_consolidation_" {
return "", toolsUsed, fmt.Errorf("interrupted by user input")
}
// 用户对话被打断:继续下一轮 drain 打断消息,注入到当前对话上下文
continue
}
return "", toolsUsed, fmt.Errorf("all %d providers failed, last error: %w",
len(providers), llmErr)
}
@ -1159,7 +1180,11 @@ func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string {
if i >= topK {
break
}
parts = append(parts, fmt.Sprintf("[%s]\n%s", k.Name, truncateStr(k.Content, 200)))
label := k.Name
if k.Category != "" {
label = k.Category + "/" + k.Name
}
parts = append(parts, fmt.Sprintf("[%s]\n%s", label, truncateStr(k.Content, 200)))
}
return strings.Join(parts, "\n---\n")
@ -1175,11 +1200,8 @@ func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string {
return fmt.Sprintf("知识「%s」已创建并向量化索引%d 字符)", name, len(content))
case "knowledge_list":
names := a.knowledge.List()
if len(names) == 0 {
return "知识库为空"
}
return "知识分类: " + strings.Join(names, ", ")
tree := a.knowledge.BuildTree()
return formatTree(tree, 0)
default:
return fmt.Sprintf("未知的知识工具: %s", tc.Name)
@ -1802,9 +1824,14 @@ func (a *Agent) distillContext() {
if a.docStore == nil {
return
}
// 心跳时执行一次安全裁剪(兜底)
// 上下文的主要裁剪在 processTextInput 中基于相关性执行
_ = a.context.Len()
// 心跳时执行安全裁剪:上下文超过 maxContextSize*2 时强制归档
n := a.context.Len()
if n > a.maxContextSize*2 {
archived := a.context.Prune("", a.maxContextSize, a.docStore)
if archived > 0 {
log.Printf("[agent] distill: pruned %d low-relevance events to document memory (total=%d)", archived, n)
}
}
}
// syncGraphToDocs — 将图记忆的实体和关系注入文档记忆层
@ -2104,6 +2131,13 @@ func (a *Agent) emitMemoryCandidate(source, input, response string, toolsUsed []
func (a *Agent) processConsolidation(input string) {
start := time.Now()
a.currentOutputChannel = "_consolidation_"
// 遗忘不相关的旧事件
archived := a.context.Prune(input, a.maxContextSize-1, a.docStore)
if archived > 0 {
log.Printf("[agent] consolidation: pruned %d low-relevance events", archived)
}
a.context.Append(ContextEvent{
Timestamp: start,
Source: "system",
@ -2121,7 +2155,6 @@ func (a *Agent) processConsolidation(input string) {
Response: response,
ToolsUsed: toolsUsed,
})
_ = a.context.Prune(response, a.maxContextSize, a.docStore)
a.emitMemoryCandidate("system", input, response, toolsUsed)
log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed)
}
@ -2585,6 +2618,45 @@ func truncateStr(s string, max int) string {
return s
}
func formatTree(node *knowledge.TreeIndex, depth int) string {
var sb strings.Builder
indent := strings.Repeat(" ", depth)
for _, child := range node.Children {
sb.WriteString(fmt.Sprintf("%s%s/\n", indent, child.Name))
if len(child.Items) > 0 {
for _, item := range child.Items {
preview := item.Preview
if len([]rune(preview)) > 60 {
preview = string([]rune(preview)[:60]) + "..."
}
tags := ""
if len(item.Tags) > 0 {
tags = " [" + strings.Join(item.Tags, ", ") + "]"
}
sb.WriteString(fmt.Sprintf("%s · %s%s\n %s\n", indent, item.Name, tags, preview))
}
}
sb.WriteString(formatTree(child, depth+1))
}
if depth > 0 && len(node.Items) > 0 {
for _, item := range node.Items {
preview := item.Preview
if len([]rune(preview)) > 60 {
preview = string([]rune(preview)[:60]) + "..."
}
tags := ""
if len(item.Tags) > 0 {
tags = " [" + strings.Join(item.Tags, ", ") + "]"
}
sb.WriteString(fmt.Sprintf(" · %s%s\n %s\n", item.Name, tags, preview))
}
}
if sb.Len() == 0 {
sb.WriteString("(空)")
}
return sb.String()
}
func (a *Agent) resolveToolPlugin(name string) string {
if a.stageHost != nil {
if plugin := a.stageHost.ToolPlugin(name); plugin != "" {

View File

@ -223,6 +223,10 @@ func (c *RelevanceContext) ensureTrained() {
texts[i] = evt.Input + " " + evt.Response
}
c.veczer.Train(texts)
// 重算所有事件向量,与新的向量化器特征空间对齐
for _, evt := range c.events {
evt.Vector = c.veczer.Vectorize(evt.Input + " " + evt.Response)
}
c.trained = true
}
}