feat: NoMemory/Cleaner memory system + doc update

- _sdk_local/ removed (moved to standalone sdk repo)
- internal/agent/core: NoMemory/Cleaner data-flow breakpoints
- internal/memory: clean_text, document store refactor
- internal/plugin/registry.go: plugin API alignment
- docs: PLUGIN_DEV.md, ARCHITECTURE.md NoMemory/Cleaner docs
- plan.md, review.md: status update
This commit is contained in:
JianFeeeee
2026-07-25 11:17:31 +08:00
parent a51124aa92
commit b31db0f88e
36 changed files with 639 additions and 2488 deletions

View File

@ -16,13 +16,19 @@ import (
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
type ToolResultItem struct {
Name string `json:"name"`
Output string `json:"output"`
}
type ContextEvent struct {
Timestamp time.Time `json:"timestamp"`
Source string `json:"source"`
Input string `json:"input"`
Response string `json:"response,omitempty"`
ToolsUsed []string `json:"tools_used,omitempty"`
Vector vector.Vector `json:"-"`
Timestamp time.Time `json:"timestamp"`
Source string `json:"source"`
Input string `json:"input"`
Response string `json:"response,omitempty"`
ToolsUsed []string `json:"tools_used,omitempty"`
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
Vector vector.Vector `json:"-"`
}
const contextFlushInterval = 5 * time.Second
@ -64,25 +70,49 @@ func (c *RelevanceContext) load() {
return
}
for _, evt := range events {
evt.Input = memory.CleanText(evt.Input)
evt.Vector = c.computeVector(evt)
}
c.events = events
}
func textForVector(evt *ContextEvent) string {
func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolDef) string {
var text string
switch {
case evt.Source == "agent" && evt.Response != "":
return memory.CleanText(evt.Response)
text = evt.Response
case evt.Source == "cold_storage":
return memory.CleanText(evt.Input + " " + evt.Response)
text = evt.Input + " " + evt.Response
default:
return memory.CleanText(evt.Input)
text = evt.Input
}
// 计算层:附加工具输出,NoMemory 跳过,其余经 Cleaner 过滤
if toolDefLookup != nil {
noMemory := make(map[string]bool)
for _, tr := range evt.ToolResults {
def := toolDefLookup(tr.Name)
if def != nil && def.NoMemory {
noMemory[tr.Name] = true
}
}
for _, tr := range evt.ToolResults {
if noMemory[tr.Name] {
continue
}
cleaned := tr.Output
def := toolDefLookup(tr.Name)
if def != nil && def.Cleaner != nil {
cleaned = def.Cleaner(cleaned)
}
text += " " + cleaned
}
}
return memory.CleanText(text)
}
func (c *RelevanceContext) computeVector(evt *ContextEvent) vector.Vector {
return c.embedder.Vectorize(textForVector(evt))
return c.embedder.Vectorize(textForVector(evt, c.toolDefLookup))
}
func (c *RelevanceContext) Save() error {
@ -103,7 +133,6 @@ func (c *RelevanceContext) Append(evt ContextEvent) {
c.mu.Lock()
defer c.mu.Unlock()
evt.Input = memory.CleanText(evt.Input)
evt.Vector = c.computeVector(&evt)
c.events = append(c.events, &evt)
@ -199,25 +228,19 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
archived := 0
if docStore != nil && len(archive) > 0 {
var filtered []scored
for _, s := range archive {
if hasNoMemoryTool(s.event.ToolsUsed, c.toolDefLookup) {
continue
}
filtered = append(filtered, s)
}
entries := make([]document.ContextEntry, len(filtered))
for i, s := range filtered {
entries := make([]document.ContextEntry, len(archive))
for i, s := range archive {
entries[i] = document.ContextEntry{
Timestamp: s.event.Timestamp,
Source: s.event.Source,
Content: s.event.Input,
Response: s.event.Response,
Timestamp: s.event.Timestamp,
Source: s.event.Source,
Content: s.event.Input,
Response: s.event.Response,
ToolResults: convertToolResults(s.event.ToolResults),
}
}
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder)
if err == nil && doc != nil {
archived = len(filtered)
archived = len(entries)
}
}
@ -266,14 +289,15 @@ func (c *RelevanceContext) Len() int {
return len(c.events)
}
func hasNoMemoryTool(toolsUsed []string, lookup func(string) *sdk.ToolDef) bool {
if lookup == nil {
return false
func convertToolResults(items []ToolResultItem) []document.ToolResultItem {
if items == nil {
return nil
}
for _, name := range toolsUsed {
if def := lookup(name); def != nil && def.NoMemory {
return true
}
result := make([]document.ToolResultItem, len(items))
for i, item := range items {
result[i] = document.ToolResultItem{Name: item.Name, Output: item.Output}
}
return false
return result
}

View File

@ -6,7 +6,6 @@ import (
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func newTestCtx() *RelevanceContext {
@ -238,40 +237,6 @@ func containsStr(s, substr string) bool {
return false
}
func TestHasNoMemoryTool(t *testing.T) {
host := NewStageHost()
host.RegisterTool("no_mem_tool", sdk.ToolDef{Name: "no_mem_tool", NoMemory: true}, nil)
host.RegisterTool("mem_tool", sdk.ToolDef{Name: "mem_tool"}, nil)
lookup := host.ToolDef
gotNil := hasNoMemoryTool([]string{"no_mem_tool"}, nil)
if gotNil {
t.Error("hasNoMemoryTool with nil lookup should return false")
}
tests := []struct {
name string
toolsUsed []string
want bool
}{
{"empty tools", nil, false},
{"no matching tool", []string{"unknown"}, false},
{"tool without NoMemory", []string{"mem_tool"}, false},
{"tool with NoMemory", []string{"no_mem_tool"}, true},
{"mixed tools, first is no_memory", []string{"no_mem_tool", "mem_tool"}, true},
{"mixed tools, last is no_memory", []string{"mem_tool", "no_mem_tool"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := hasNoMemoryTool(tt.toolsUsed, lookup)
if got != tt.want {
t.Errorf("hasNoMemoryTool(%v) = %v, want %v", tt.toolsUsed, got, tt.want)
}
})
}
}
func splitLines(s string) []string {
var lines []string
start := 0

View File

@ -377,14 +377,15 @@ func docToTriples(doc *document.Doc) []memory.Triple {
return triples
}
func (a *Agent) emitMemoryCandidate(source, input, response string, toolsUsed []string) {
func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults []ToolResultItem, toolsUsed []string) {
a.io.EmitOutput("memory", "memory_candidate", map[string]interface{}{
"source": source,
"input": input,
"response": response,
"tools_used": toolsUsed,
"agent_id": string(a.id),
"timestamp": time.Now().Unix(),
"source": source,
"input": input,
"response": response,
"tool_results": toolResults,
"tools_used": toolsUsed,
"agent_id": string(a.id),
"timestamp": time.Now().Unix(),
})
}
@ -406,17 +407,18 @@ func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
Source: "system",
Input: input,
})
response, toolsUsed, err := a.process(input, stageCtx)
response, toolsUsed, toolResults, err := a.process(input, stageCtx)
if err != nil {
log.Printf("[agent] consolidation error: %v", err)
return
}
a.context.Append(ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: input,
Response: response,
ToolsUsed: toolsUsed,
Timestamp: time.Now(),
Source: "agent",
Input: input,
Response: response,
ToolsUsed: toolsUsed,
ToolResults: toolResults,
})
log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed)
}

View File

@ -183,7 +183,7 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
Input: fallback,
})
response, toolsUsed, err := a.process(fallback, stageCtx)
response, toolsUsed, toolResults, err := a.process(fallback, stageCtx)
if err != nil {
log.Printf("[agent] process media error: %v", err)
resp := fmt.Sprintf("处理错误: %v", err)
@ -196,17 +196,18 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
log.Printf("[agent] %s from %s → response (%dms, tools=%v)", evt.Type, evt.Source, elapsed.Milliseconds(), toolsUsed)
a.context.Append(ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: fallback,
Response: response,
ToolsUsed: toolsUsed,
Timestamp: time.Now(),
Source: "agent",
Input: fallback,
Response: response,
ToolsUsed: toolsUsed,
ToolResults: toolResults,
})
a.emitResponse(evt, response)
if !stageCtx.NoMemory && !a.hasNoMemoryTool(toolsUsed) {
a.emitMemoryCandidate(evt.Source, fallback, response, toolsUsed)
if !stageCtx.NoMemory {
a.emitMemoryCandidate(evt.Source, fallback, response, toolResults, toolsUsed)
}
}
@ -312,7 +313,7 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
Input: input,
})
response, toolsUsed, err := a.process(input, stageCtx)
response, toolsUsed, toolResults, err := a.process(input, stageCtx)
if err != nil {
log.Printf("[agent] process error: %v", err)
resp := fmt.Sprintf("处理错误: %v", err)
@ -325,17 +326,18 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
log.Printf("[agent] input from %s → response (%dms, tools=%v)", evt.Source, elapsed.Milliseconds(), toolsUsed)
a.context.Append(ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: input,
Response: response,
ToolsUsed: toolsUsed,
Timestamp: time.Now(),
Source: "agent",
Input: input,
Response: response,
ToolsUsed: toolsUsed,
ToolResults: toolResults,
})
a.emitResponse(evt, response)
if !stageCtx.NoMemory && !a.hasNoMemoryTool(toolsUsed) {
a.emitMemoryCandidate(evt.Source, input, response, toolsUsed)
if !stageCtx.NoMemory {
a.emitMemoryCandidate(evt.Source, input, response, toolResults, toolsUsed)
}
}
@ -386,15 +388,6 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
a.runStage(sdk.StageAfterOutput, stageCtx)
}
func (a *Agent) hasNoMemoryTool(toolsUsed []string) bool {
for _, name := range toolsUsed {
if def := a.stageHost.ToolDef(name); def != nil && def.NoMemory {
return true
}
}
return false
}
func (a *Agent) drainInterrupts() []string {
var out []string
for {

View File

@ -15,12 +15,12 @@ import (
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, err error) {
func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, toolResults []ToolResultItem, err error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.provider == nil {
return "", nil, fmt.Errorf("agent: no LLM provider configured")
return "", nil, nil, fmt.Errorf("agent: no LLM provider configured")
}
memContext := a.buildMemoryContext(input)
@ -40,7 +40,7 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
a.docStoreSize())
if a.runStage(sdk.StagePreAction, stageCtx) {
return *stageCtx.Response, toolsUsed, nil
return *stageCtx.Response, toolsUsed, toolResults, nil
}
if len(stageCtx.ContextMsgs) > 0 {
for _, m := range stageCtx.ContextMsgs {
@ -132,11 +132,11 @@ 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")
return "", toolsUsed, toolResults, fmt.Errorf("interrupted by user input")
}
continue
}
return "", toolsUsed, fmt.Errorf("all %d providers failed, last error: %w",
return "", toolsUsed, toolResults, fmt.Errorf("all %d providers failed, last error: %w",
len(providers), llmErr)
}
@ -154,7 +154,7 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
}
}
if a.runStage(sdk.StagePostAction, stageCtx) {
return *stageCtx.Response, toolsUsed, nil
return *stageCtx.Response, toolsUsed, toolResults, nil
}
resp.Content = stageCtx.LLMText
resp.ToolCalls = convertBackToolCalls(stageCtx.ToolCalls)
@ -176,7 +176,7 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
a.publishEvent(events.EventAgentLLMChain, chainPayload)
if len(resp.ToolCalls) == 0 {
return resp.Content, toolsUsed, nil
return resp.Content, toolsUsed, toolResults, nil
}
contentOnce := true
@ -226,6 +226,7 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
}
result := a.executeToolCall(tc)
toolResults = append(toolResults, ToolResultItem{Name: tc.Name, Output: result})
log.Printf("[agent] tool %s result: %s", tc.Name, truncateStr(result, 100))
stageCtx.ToolResults = []sdk.ToolResult{{CallID: tc.ID, Name: tc.Name, Plugin: pluginName, Success: true, Result: result}}

View File

@ -159,6 +159,29 @@ func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
}
}
func (h *StageHost) ToolDefCleaner(name string) func(string) string {
h.mu.RLock()
defer h.mu.RUnlock()
for _, def := range h.toolDefs {
if def.Name == name {
return def.Cleaner
}
}
return nil
}
func (h *StageHost) NoMemoryToolNames() map[string]bool {
h.mu.RLock()
defer h.mu.RUnlock()
set := make(map[string]bool, len(h.toolDefs))
for _, def := range h.toolDefs {
if def.NoMemory {
set[def.Name] = true
}
}
return set
}
func (h *StageHost) ToolCount() int {
h.mu.RLock()
defer h.mu.RUnlock()