diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index f0273319..d7bb2122 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -399,8 +399,8 @@ func TestDefaultBuilderBuildAppliesMicroCompactAfterTrim(t *testing.T) { if len(got.Messages) != len(messages) { t.Fatalf("expected builder output to keep message count, got %d want %d", len(got.Messages), len(messages)) } - if renderDisplayParts(got.Messages[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected builder output to clear older tool result, got %q", renderDisplayParts(got.Messages[2].Parts)) + if !strings.Contains(renderDisplayParts(got.Messages[2].Parts), "[summary] filesystem_read_file") { + t.Fatalf("expected builder output to summarize older tool result, got %q", renderDisplayParts(got.Messages[2].Parts)) } if renderDisplayParts(got.Messages[4].Parts) != "recent bash result" { t.Fatalf("expected recent tool result to stay visible, got %q", renderDisplayParts(got.Messages[4].Parts)) @@ -513,8 +513,8 @@ func TestDefaultBuilderBuildRespectsExplicitPinCheckerOverride(t *testing.T) { if err != nil { t.Fatalf("Build() error = %v", err) } - if renderDisplayParts(got.Messages[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected explicit noop pin checker to allow compaction, got %q", renderDisplayParts(got.Messages[2].Parts)) + if !strings.Contains(renderDisplayParts(got.Messages[2].Parts), "[summary] filesystem_write_file") { + t.Fatalf("expected explicit noop pin checker to allow compaction into summary, got %q", renderDisplayParts(got.Messages[2].Parts)) } } @@ -1116,8 +1116,8 @@ func TestNewConfiguredBuilder(t *testing.T) { if err != nil { t.Fatalf("Build() error = %v", err) } - if renderDisplayParts(got.Messages[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected noop pin checker to allow compaction, got %q", renderDisplayParts(got.Messages[2].Parts)) + if !strings.Contains(renderDisplayParts(got.Messages[2].Parts), "[summary] filesystem_write_file") { + t.Fatalf("expected noop pin checker to allow compaction into summary, got %q", renderDisplayParts(got.Messages[2].Parts)) } }) diff --git a/internal/context/microcompact.go b/internal/context/microcompact.go index 399c34ef..050a543e 100644 --- a/internal/context/microcompact.go +++ b/internal/context/microcompact.go @@ -1,7 +1,9 @@ package context import ( + "strconv" "strings" + "unicode/utf8" "neo-code/internal/config" "neo-code/internal/context/internalcompact" @@ -235,32 +237,63 @@ func summarizeOrClear( toolNames map[string]string, summarizers MicroCompactSummarizerSource, ) string { - if summarizers == nil { - return microCompactClearedMessage - } - callID := strings.TrimSpace(message.ToolCallID) toolName, ok := toolNames[callID] if !ok { return microCompactClearedMessage } - summarizer := summarizers.MicroCompactSummarizer(toolName) - if summarizer == nil { - return microCompactClearedMessage + if summarizers != nil { + summarizer := summarizers.MicroCompactSummarizer(toolName) + if summarizer != nil { + summary := summarizer(content, message.ToolMetadata, message.IsError) + if summary != "" { + summary = sanitizeMicroCompactSummary(summary) + if summary != "" { + return summary + } + } + } } - summary := summarizer(content, message.ToolMetadata, message.IsError) - if summary == "" { - return microCompactClearedMessage - } - summary = sanitizeMicroCompactSummary(summary) + summary := sanitizeMicroCompactSummary(fallbackSummary(toolName, content)) if summary == "" { return microCompactClearedMessage } return summary } +// fallbackSummary 为缺少专用摘要器的工具生成最小可读摘要,避免静默清空历史。 +func fallbackSummary(toolName string, content string) string { + trimmedName := strings.TrimSpace(toolName) + if trimmedName == "" { + return "" + } + + parts := []string{ + "[summary]", + trimmedName, + "lines=" + strconv.Itoa(stableLineCount(content)), + "chars=" + strconv.Itoa(utf8.RuneCountInString(content)), + } + return strings.Join(parts, " ") +} + +// stableLineCount 统计文本行数;空文本返回 0,末尾换行不会产生额外空行计数。 +func stableLineCount(text string) int { + if text == "" { + return 0 + } + count := strings.Count(text, "\n") + 1 + if strings.HasSuffix(text, "\n") { + count-- + } + if count < 0 { + return 0 + } + return count +} + // sanitizeMicroCompactSummary 对 summarizer 输出做最终净化与限长,避免把不安全文本直接回灌上下文。 func sanitizeMicroCompactSummary(summary string) string { trimmed := strings.TrimSpace(summary) diff --git a/internal/context/microcompact_summarizer_test.go b/internal/context/microcompact_summarizer_test.go index 949edb4e..e1b010e8 100644 --- a/internal/context/microcompact_summarizer_test.go +++ b/internal/context/microcompact_summarizer_test.go @@ -78,8 +78,8 @@ func TestMicroCompactWithSummarizerProducesSummary(t *testing.T) { } } -// TestMicroCompactWithoutSummarizerFallsBackToClear 验证未注册 summarizer 的工具仍使用清除占位。 -func TestMicroCompactWithoutSummarizerFallsBackToClear(t *testing.T) { +// TestMicroCompactWithoutSummarizerFallsBackToSummary 验证未注册 summarizer 的工具使用通用兜底摘要。 +func TestMicroCompactWithoutSummarizerFallsBackToSummary(t *testing.T) { t.Parallel() messages := []providertypes.Message{ @@ -121,9 +121,12 @@ func TestMicroCompactWithoutSummarizerFallsBackToClear(t *testing.T) { nil, ) - // read_file 没有 summarizer,应回退到清除 - if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected cleared placeholder for read_file without summarizer, got %q", renderDisplayParts(got[2].Parts)) + summary := renderDisplayParts(got[2].Parts) + if summary == microCompactClearedMessage { + t.Fatalf("expected fallback summary for read_file without summarizer, got cleared placeholder") + } + if !strings.Contains(summary, "[summary] filesystem_read_file") { + t.Fatalf("expected fallback summary to include tool name, got %q", summary) } } @@ -176,14 +179,17 @@ func TestMicroCompactMixedSpanWithSummarizer(t *testing.T) { if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary]") { t.Fatalf("expected bash summary in old span, got %q", renderDisplayParts(got[2].Parts)) } - // call-2 read_file 在旧 span,没有 summarizer,应清除 - if renderDisplayParts(got[3].Parts) != microCompactClearedMessage { - t.Fatalf("expected read_file cleared in old span, got %q", renderDisplayParts(got[3].Parts)) + summary := renderDisplayParts(got[3].Parts) + if summary == microCompactClearedMessage { + t.Fatalf("expected read_file fallback summary in old span, got cleared placeholder") + } + if !strings.Contains(summary, "[summary] filesystem_read_file") { + t.Fatalf("expected read_file fallback summary to include tool name, got %q", summary) } } -// TestMicroCompactSummarizerReturnsEmptyFallsBackToClear 验证 summarizer 返回空字符串时回退到清除。 -func TestMicroCompactSummarizerReturnsEmptyFallsBackToClear(t *testing.T) { +// TestMicroCompactSummarizerReturnsEmptyFallsBackToSummary 验证 summarizer 返回空字符串时回退到通用摘要。 +func TestMicroCompactSummarizerReturnsEmptyFallsBackToSummary(t *testing.T) { t.Parallel() messages := []providertypes.Message{ @@ -224,8 +230,12 @@ func TestMicroCompactSummarizerReturnsEmptyFallsBackToClear(t *testing.T) { nil, ) - if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected cleared fallback when summarizer returns empty, got %q", renderDisplayParts(got[2].Parts)) + summary := renderDisplayParts(got[2].Parts) + if summary == microCompactClearedMessage { + t.Fatalf("expected fallback summary when summarizer returns empty, got cleared placeholder") + } + if !strings.Contains(summary, "[summary] bash") { + t.Fatalf("expected fallback summary to include tool name, got %q", summary) } } @@ -244,6 +254,26 @@ func TestSummarizeOrClearWithNilSummarizers(t *testing.T) { } } +func TestSummarizeOrClearFallsBackWithoutRegisteredSummarizer(t *testing.T) { + t.Parallel() + + got := summarizeOrClear( + providertypes.Message{ToolCallID: "call-1"}, + "first line\nsecond line", + map[string]string{"call-1": "mcp.github.issue"}, + nil, + ) + if got == microCompactClearedMessage { + t.Fatalf("expected fallback summary for MCP tool, got cleared placeholder") + } + if !strings.Contains(got, "[summary] mcp.github.issue") { + t.Fatalf("expected MCP tool name in fallback summary, got %q", got) + } + if !strings.Contains(got, "lines=2") { + t.Fatalf("expected line count in fallback summary, got %q", got) + } +} + // TestSummarizeOrClearWithToolNamesLookup 验证 toolNames map 查找工具名。 func TestSummarizeOrClearWithToolNamesLookup(t *testing.T) { t.Parallel() @@ -321,8 +351,11 @@ func TestSummarizeOrClearSanitizationEmptyFallback(t *testing.T) { }, ) - if got != microCompactClearedMessage { - t.Fatalf("expected cleared fallback when sanitized summary is empty, got %q", got) + if got == microCompactClearedMessage { + t.Fatalf("expected fallback summary when sanitized summary is empty, got cleared placeholder") + } + if !strings.Contains(got, "[summary] bash") { + t.Fatalf("expected fallback summary to include tool name, got %q", got) } } diff --git a/internal/context/microcompact_test.go b/internal/context/microcompact_test.go index cdada98a..5b1bdf54 100644 --- a/internal/context/microcompact_test.go +++ b/internal/context/microcompact_test.go @@ -51,8 +51,8 @@ func TestMicroCompactMessagesClearsOlderCompactableToolResults(t *testing.T) { if len(got) != len(messages) { t.Fatalf("expected message count to stay unchanged, got %d want %d", len(got), len(messages)) } - if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected oldest compactable tool result to be cleared, got %q", renderDisplayParts(got[2].Parts)) + if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] filesystem_read_file") { + t.Fatalf("expected oldest compactable tool result to fall back to summary, got %q", renderDisplayParts(got[2].Parts)) } if renderDisplayParts(got[4].Parts) != "recent bash result" { t.Fatalf("expected recent compactable tool result to be retained, got %q", renderDisplayParts(got[4].Parts)) @@ -123,8 +123,8 @@ func TestMicroCompactMessagesKeepsProtectedTailUntouched(t *testing.T) { } got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 2, nil, nil) - if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected old tool result before protected tail to be cleared, got %q", renderDisplayParts(got[2].Parts)) + if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] filesystem_grep") { + t.Fatalf("expected old tool result before protected tail to fall back to summary, got %q", renderDisplayParts(got[2].Parts)) } if renderDisplayParts(got[4].Parts) != "recent read result" { t.Fatalf("expected recent tool result before protected tail to remain, got %q", renderDisplayParts(got[4].Parts)) @@ -227,8 +227,8 @@ func TestMicroCompactMessagesClearsOnlyNonPreservedResultsInMixedToolSpan(t *tes got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ "custom_tool": tools.MicroCompactPolicyPreserveHistory, }, 2, nil, nil) - if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected default compactable tool result to be cleared, got %q", renderDisplayParts(got[2].Parts)) + if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] filesystem_read_file") { + t.Fatalf("expected default compactable tool result to fall back to summary, got %q", renderDisplayParts(got[2].Parts)) } if renderDisplayParts(got[3].Parts) != "custom result" { t.Fatalf("expected preserved tool result in mixed span to remain, got %q", renderDisplayParts(got[3].Parts)) @@ -268,8 +268,80 @@ func TestMicroCompactMessagesTreatsNewToolsAsCompactableByDefault(t *testing.T) } got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 2, nil, nil) - if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected new tool result to be compacted by default, got %q", renderDisplayParts(got[2].Parts)) + if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] repo_search") { + t.Fatalf("expected new tool result to be compacted into fallback summary by default, got %q", renderDisplayParts(got[2].Parts)) + } +} + +func TestMicroCompactMessagesPreservesSpawnSubAgentHistory(t *testing.T) { + t.Parallel() + + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-1", Name: tools.ToolNameSpawnSubAgent, Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("spawned analysis")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-2", Name: tools.ToolNameBash, Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-3", Name: tools.ToolNameWebFetch, Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + } + + got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ + tools.ToolNameSpawnSubAgent: tools.MicroCompactPolicyPreserveHistory, + }, 1, nil, nil) + if renderDisplayParts(got[2].Parts) != "spawned analysis" { + t.Fatalf("expected spawn_subagent history to be preserved, got %q", renderDisplayParts(got[2].Parts)) + } +} + +func TestMicroCompactMessagesCompactsCodebaseReadToSummary(t *testing.T) { + t.Parallel() + + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-1", Name: tools.ToolNameCodebaseRead, Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("path: main.go\n\npackage main")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-2", Name: tools.ToolNameBash, Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-3", Name: tools.ToolNameWebFetch, Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + } + + got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 2, nil, nil) + if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] codebase_read") { + t.Fatalf("expected codebase_read history to fall back to summary, got %q", renderDisplayParts(got[2].Parts)) } } @@ -318,8 +390,8 @@ func TestMicroCompactMessagesSkipsEmptyRecentSpansWhenCountingRetainedBudget(t * } got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 2, nil, nil) - if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected oldest valid tool result to be cleared, got %q", renderDisplayParts(got[2].Parts)) + if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] filesystem_read_file") { + t.Fatalf("expected oldest valid tool result to fall back to summary, got %q", renderDisplayParts(got[2].Parts)) } if renderDisplayParts(got[4].Parts) != "middle grep result" { t.Fatalf("expected middle valid tool result to remain, got %q", renderDisplayParts(got[4].Parts)) @@ -423,8 +495,103 @@ func TestMicroCompactMixedPinnedAndNonPinned(t *testing.T) { if renderDisplayParts(got[2].Parts) != "README content" { t.Fatalf("expected pinned README result preserved, got %q", renderDisplayParts(got[2].Parts)) } - if renderDisplayParts(got[3].Parts) != microCompactClearedMessage { - t.Fatalf("expected non-pinned main.go result to be cleared, got %q", renderDisplayParts(got[3].Parts)) + if !strings.Contains(renderDisplayParts(got[3].Parts), "[summary] filesystem_write_file") { + t.Fatalf("expected non-pinned main.go result to fall back to summary, got %q", renderDisplayParts(got[3].Parts)) + } +} + +func TestMicroCompactPinsCopyAndMoveUsingPersistedMetadataPaths(t *testing.T) { + t.Parallel() + + pinChecker := NewDefaultPinChecker() + + copyMessages := []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "copy-call", Name: tools.ToolNameFilesystemCopyFile, Arguments: `{"source_path":"main.go","destination_path":"go.mod"}`}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "copy-call", Parts: []providertypes.ContentPart{providertypes.NewTextPart("ok")}, ToolMetadata: map[string]string{ + "tool_name": tools.ToolNameFilesystemCopyFile, + "source_path": "/project/main.go", + "destination_path": "/project/go.mod", + }}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "recent-call", Name: tools.ToolNameBash, Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "recent-call", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + } + + copyGot := microCompactMessagesWithPolicies(copyMessages, stubMicroCompactPolicySource{}, 1, nil, pinChecker) + if renderDisplayParts(copyGot[2].Parts) != "ok" { + t.Fatalf("expected copy_file result touching go.mod to stay pinned, got %q", renderDisplayParts(copyGot[2].Parts)) + } + + moveMessages := []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "move-call", Name: tools.ToolNameFilesystemMoveFile, Arguments: `{"source_path":"package.json","destination_path":"package.backup.json"}`}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "move-call", Parts: []providertypes.ContentPart{providertypes.NewTextPart("ok")}, ToolMetadata: map[string]string{ + "tool_name": tools.ToolNameFilesystemMoveFile, + "source_path": "/project/package.json", + "destination_path": "/project/package.backup.json", + }}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "recent-call", Name: tools.ToolNameBash, Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "recent-call", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + } + + moveGot := microCompactMessagesWithPolicies(moveMessages, stubMicroCompactPolicySource{}, 1, nil, pinChecker) + if renderDisplayParts(moveGot[2].Parts) != "ok" { + t.Fatalf("expected move_file result touching package.json to stay pinned, got %q", renderDisplayParts(moveGot[2].Parts)) + } +} + +func TestMicroCompactStillCompactsCopyAndMoveWhenNoKeyFileIsTouched(t *testing.T) { + t.Parallel() + + pinChecker := NewDefaultPinChecker() + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "move-call", Name: tools.ToolNameFilesystemMoveFile, Arguments: `{"source_path":"main.go","destination_path":"main2.go"}`}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "move-call", Parts: []providertypes.ContentPart{providertypes.NewTextPart("ok")}, ToolMetadata: map[string]string{ + "tool_name": tools.ToolNameFilesystemMoveFile, + "source_path": "/project/main.go", + "destination_path": "/project/main2.go", + }}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "recent-call", Name: tools.ToolNameBash, Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "recent-call", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + } + + got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 1, nil, pinChecker) + if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] filesystem_move_file") { + t.Fatalf("expected non-key move_file result to still compact into summary, got %q", renderDisplayParts(got[2].Parts)) } } diff --git a/internal/context/pin_checker.go b/internal/context/pin_checker.go index 3377a19a..550c3835 100644 --- a/internal/context/pin_checker.go +++ b/internal/context/pin_checker.go @@ -23,6 +23,8 @@ var defaultPinPatterns = []string{ var defaultPinToolNames = map[string]struct{}{ tools.ToolNameFilesystemWriteFile: {}, tools.ToolNameFilesystemEdit: {}, + tools.ToolNameFilesystemCopyFile: {}, + tools.ToolNameFilesystemMoveFile: {}, } // pinChecker 基于文件路径 glob 模式判断工具结果是否应钉住。 @@ -44,21 +46,32 @@ func (p *pinChecker) ShouldPin(toolName string, metadata map[string]string) bool return false } - path := metadata["relative_path"] - if path == "" { - path = metadata["path"] - } - if path == "" { - return false + for _, path := range candidatePinPaths(metadata) { + base := filepath.Base(path) + for _, pattern := range p.patterns { + if matched, _ := filepath.Match(pattern, base); matched { + return true + } + } } + return false +} - base := filepath.Base(path) - for _, pattern := range p.patterns { - if matched, _ := filepath.Match(pattern, base); matched { - return true +// candidatePinPaths 按稳定顺序提取可参与 pin 判断的文件路径字段。 +func candidatePinPaths(metadata map[string]string) []string { + keys := []string{"relative_path", "path", "source_path", "destination_path"} + paths := make([]string, 0, len(keys)) + for _, key := range keys { + path := strings.TrimSpace(metadata[key]) + if path == "" { + continue } + paths = append(paths, path) } - return false + if len(paths) == 0 { + return nil + } + return paths } // toolSupportsPinnedRetention 判断工具是否允许参与默认 pin 策略,避免非文件修改类工具扩大保留范围。 diff --git a/internal/context/pin_checker_test.go b/internal/context/pin_checker_test.go index f4d9a34f..7bc9cc5a 100644 --- a/internal/context/pin_checker_test.go +++ b/internal/context/pin_checker_test.go @@ -39,6 +39,8 @@ func TestDefaultPinCheckerMatchesKeyArtifacts(t *testing.T) { {toolName: "filesystem_write_file", path: "utils.py", expected: false}, {toolName: "filesystem_write_file", path: "style.css", expected: false}, {toolName: "filesystem_edit", path: "README.md", expected: true}, + {toolName: "filesystem_copy_file", path: "go.mod", expected: true}, + {toolName: "filesystem_move_file", path: "package.json", expected: true}, {toolName: "filesystem_read_file", path: "README.md", expected: false}, {toolName: "bash", path: "README.md", expected: false}, } @@ -78,6 +80,34 @@ func TestDefaultPinCheckerFallsBackToPath(t *testing.T) { } } +func TestDefaultPinCheckerSupportsCopyAndMoveMetadataFields(t *testing.T) { + t.Parallel() + + checker := NewDefaultPinChecker() + + copyPinned := checker.ShouldPin("filesystem_copy_file", map[string]string{ + "destination_path": "/project/go.mod", + }) + if !copyPinned { + t.Error("expected destination_path match for copy_file go.mod") + } + + movePinned := checker.ShouldPin("filesystem_move_file", map[string]string{ + "source_path": "/project/package.json", + }) + if !movePinned { + t.Error("expected source_path match for move_file package.json") + } + + notPinned := checker.ShouldPin("filesystem_move_file", map[string]string{ + "source_path": "/project/main.go", + "destination_path": "/project/main2.go", + }) + if notPinned { + t.Error("expected non-key source/destination paths to remain unpinned") + } +} + func TestDefaultPinCheckerNoPathReturnsFalse(t *testing.T) { t.Parallel() diff --git a/internal/context/projection.go b/internal/context/projection.go index 265beb0c..9db200df 100644 --- a/internal/context/projection.go +++ b/internal/context/projection.go @@ -10,9 +10,12 @@ import ( const ( recentWindowAbsoluteMessageLimit = 24 - recentWindowToolContentCharLimit = 600 + recentWindowToolContentHeadChars = 300 + recentWindowToolContentTailChars = 300 ) +const truncatedExcerptMarker = "\n...[truncated]...\n" + // ProjectToolMessagesForModel 原地投影 tool 消息,复用主链路对模型可见的只读格式化规则。 func ProjectToolMessagesForModel(messages []providertypes.Message) []providertypes.Message { for i := range messages { @@ -229,7 +232,7 @@ func sanitizeProjectedToolContent(content string) string { return prefix } - limited, truncated := truncateUTF8(body, recentWindowToolContentCharLimit) + limited, truncated := sanitizeToolExcerpt(body) lines := []string{prefix, "content_excerpt:", limited} if truncated { lines = append(lines, "[content truncated for memo extraction]") @@ -243,7 +246,7 @@ func sanitizeRawToolContent(content string) string { if body == "" { return "" } - limited, truncated := truncateUTF8(body, recentWindowToolContentCharLimit) + limited, truncated := sanitizeToolExcerpt(body) if !truncated { return body } @@ -254,21 +257,58 @@ func sanitizeRawToolContent(content string) string { }, "\n") } -// truncateUTF8 按 rune 数量截断字符串,返回截断后的文本及是否发生截断。 -func truncateUTF8(text string, maxRunes int) (string, bool) { +// sanitizeToolExcerpt 保留工具输出的头尾窗口,避免 memo 提取遗漏尾部关键错误。 +func sanitizeToolExcerpt(text string) (string, bool) { + total := utf8.RuneCountInString(text) + limit := recentWindowToolContentHeadChars + recentWindowToolContentTailChars + if limit <= 0 || text == "" { + return "", total > 0 + } + if total <= limit { + return text, false + } + + head := headRunes(text, recentWindowToolContentHeadChars) + tail := tailRunes(text, recentWindowToolContentTailChars) + return head + truncatedExcerptMarker + tail, true +} + +// headRunes 返回文本前 maxRunes 个 rune。 +func headRunes(text string, maxRunes int) string { if maxRunes <= 0 || text == "" { - return "", text != "" + return "" } if utf8.RuneCountInString(text) <= maxRunes { - return text, false + return text } count := 0 for index := range text { if count == maxRunes { - return text[:index], true + return text[:index] + } + count++ + } + return text +} + +// tailRunes 返回文本后 maxRunes 个 rune。 +func tailRunes(text string, maxRunes int) string { + if maxRunes <= 0 || text == "" { + return "" + } + total := utf8.RuneCountInString(text) + if total <= maxRunes { + return text + } + + startRune := total - maxRunes + count := 0 + for index := range text { + if count == startRune { + return text[index:] } count++ } - return text, false + return text } diff --git a/internal/context/projection_test.go b/internal/context/projection_test.go index 80edb854..8e78a870 100644 --- a/internal/context/projection_test.go +++ b/internal/context/projection_test.go @@ -229,7 +229,7 @@ func TestBuildRecentMessagesForModelRespectsAbsoluteMessageBudget(t *testing.T) func TestSanitizeProjectedToolContent(t *testing.T) { t.Parallel() - rawBody := strings.Repeat("甲", recentWindowToolContentCharLimit+10) + rawBody := strings.Repeat("A", recentWindowToolContentHeadChars+10) + strings.Repeat("B", recentWindowToolContentTailChars-20) + "TAIL-MARKER" projected := "tool result\nstatus: ok\n\ncontent:\n" + rawBody sanitized := sanitizeProjectedToolContent(projected) if !strings.Contains(sanitized, "content_excerpt:") { @@ -238,6 +238,12 @@ func TestSanitizeProjectedToolContent(t *testing.T) { if strings.Contains(sanitized, "content:\n") { t.Fatalf("expected original content marker removed, got %q", sanitized) } + if !strings.Contains(sanitized, "...[truncated]...") { + t.Fatalf("expected middle truncation marker, got %q", sanitized) + } + if !strings.Contains(sanitized, "TAIL-MARKER") { + t.Fatalf("expected tail content to be preserved, got %q", sanitized) + } if !strings.Contains(sanitized, "[content truncated for memo extraction]") { t.Fatalf("expected truncation marker, got %q", sanitized) } @@ -390,11 +396,17 @@ func TestIsInjectableToolMessage(t *testing.T) { func TestSanitizeProjectedToolContentFallsBackForRawPayload(t *testing.T) { t.Parallel() - raw := strings.Repeat("x", recentWindowToolContentCharLimit+10) + raw := strings.Repeat("x", recentWindowToolContentHeadChars+20) + strings.Repeat("y", recentWindowToolContentTailChars-10) + "RAW-TAIL" sanitized := sanitizeProjectedToolContent(raw) if !strings.Contains(sanitized, "content_excerpt:") { t.Fatalf("expected raw payload to be excerpted, got %q", sanitized) } + if !strings.Contains(sanitized, "RAW-TAIL") { + t.Fatalf("expected raw payload tail to be preserved, got %q", sanitized) + } + if !strings.Contains(sanitized, "...[truncated]...") { + t.Fatalf("expected middle truncation marker, got %q", sanitized) + } if !strings.Contains(sanitized, "[content truncated for memo extraction]") { t.Fatalf("expected truncation marker, got %q", sanitized) } diff --git a/internal/context/types.go b/internal/context/types.go index 15ed99ea..94aceb44 100644 --- a/internal/context/types.go +++ b/internal/context/types.go @@ -3,8 +3,8 @@ package context import ( "context" - "neo-code/internal/repository" providertypes "neo-code/internal/provider/types" + "neo-code/internal/repository" agentsession "neo-code/internal/session" "neo-code/internal/skills" "neo-code/internal/tools" diff --git a/internal/memo/auto_extractor.go b/internal/memo/auto_extractor.go index 812d64e8..91a4b71e 100644 --- a/internal/memo/auto_extractor.go +++ b/internal/memo/auto_extractor.go @@ -222,6 +222,24 @@ func (a *AutoExtractor) extractAndStore(extractor Extractor, messages []provider ctx, cancel := context.WithTimeout(context.Background(), a.extractTimeout) defer cancel() + if decisionExtractor, ok := extractor.(DecisionExtractor); ok { + existing, err := a.svc.autoExtractionCandidates(ctx) + if err != nil { + a.logError("memo: auto extract load candidates failed: %v", err) + return false + } + decisions, err := decisionExtractor.ExtractDecisions(ctx, messages, existing) + if err != nil { + if errors.Is(err, ErrExtractionNoJSONArray) || errors.Is(err, ErrExtractionIncompleteJSONArray) { + a.logError("memo: auto extract skipped (protocol_mismatch): %v", err) + return true + } + a.logError("memo: auto extract failed: %v", err) + return false + } + return a.applyExtractionDecisions(ctx, decisions) + } + entries, err := extractor.Extract(ctx, messages) if err != nil { if errors.Is(err, ErrExtractionNoJSONArray) || errors.Is(err, ErrExtractionIncompleteJSONArray) { @@ -304,6 +322,52 @@ func (a *AutoExtractor) extractAndStore(extractor Extractor, messages []provider return succeeded } +// applyExtractionDecisions 应用模型返回的 create/update/skip 决策,并保留本地精确去重兜底。 +func (a *AutoExtractor) applyExtractionDecisions(ctx context.Context, decisions []ExtractionDecision) bool { + if len(decisions) == 0 { + return true + } + + seenCreates := make(map[string]struct{}, len(decisions)) + succeeded := true + for _, decision := range decisions { + switch decision.Action { + case ExtractionActionCreate: + entry := decision.Entry + entry.Source = SourceAutoExtract + key := autoExtractDedupKey(entry) + if key == "" { + continue + } + if _, exists := seenCreates[key]; exists { + continue + } + added, err := a.svc.addAutoExtractIfAbsent(ctx, entry) + if err != nil { + a.logError("memo: auto extract add failed: %v", err) + succeeded = false + continue + } + seenCreates[key] = struct{}{} + if !added { + continue + } + case ExtractionActionUpdate: + entry := decision.Entry + entry.Source = SourceAutoExtract + _, err := a.svc.updateAutoExtractIfAllowed(ctx, decision.Ref, entry) + if err != nil { + a.logError("memo: auto extract update failed: %v", err) + succeeded = false + continue + } + case ExtractionActionSkip: + continue + } + } + return succeeded +} + // autoExtractDedupKey 生成自动提取条目的精确去重键。 func autoExtractDedupKey(entry Entry) string { title := NormalizeTitle(entry.Title) diff --git a/internal/memo/auto_extractor_test.go b/internal/memo/auto_extractor_test.go index 06e012c9..871618c8 100644 --- a/internal/memo/auto_extractor_test.go +++ b/internal/memo/auto_extractor_test.go @@ -1,4 +1,4 @@ -package memo +package memo import ( "context" @@ -39,38 +39,30 @@ func (s *stubMemoExtractor) Calls() int { } type stubDecisionMemoExtractor struct { - mu sync.Mutex - callCount int - candidates []ExtractionCandidate - extractEntries []Entry - decisions []ExtractionDecision - err error + mu sync.Mutex + callCount int + candidates []ExtractionCandidate + decisions []ExtractionDecision + err error } func (s *stubDecisionMemoExtractor) Extract(ctx context.Context, messages []providertypes.Message) ([]Entry, error) { - s.mu.Lock() - defer s.mu.Unlock() - return append([]Entry(nil), s.extractEntries...), nil + return nil, nil } -func (s *stubDecisionMemoExtractor) ResolveDecision( +func (s *stubDecisionMemoExtractor) ExtractDecisions( ctx context.Context, - candidate Entry, + messages []providertypes.Message, existing []ExtractionCandidate, -) (ExtractionDecision, error) { +) ([]ExtractionDecision, error) { s.mu.Lock() defer s.mu.Unlock() s.callCount++ s.candidates = append([]ExtractionCandidate(nil), existing...) if s.err != nil { - return ExtractionDecision{}, s.err - } - if len(s.decisions) == 0 { - return ExtractionDecision{Action: ExtractionActionCreate, Entry: candidate}, nil + return nil, s.err } - decision := s.decisions[0] - s.decisions = append([]ExtractionDecision(nil), s.decisions[1:]...) - return decision, nil + return append([]ExtractionDecision(nil), s.decisions...), nil } func newAutoExtractorTestService(t *testing.T) *Service { @@ -286,9 +278,6 @@ func TestAutoExtractorAppliesSemanticUpdateOnlyForAutoExtractedMemory(t *testing } extractor := &stubDecisionMemoExtractor{ - extractEntries: []Entry{ - {Type: TypeFeedback, Title: "测试策略", Content: "用户要求修改后先跑相关测试。"}, - }, decisions: []ExtractionDecision{ { Action: ExtractionActionUpdate, @@ -335,8 +324,8 @@ func TestAutoExtractorAppliesSemanticUpdateOnlyForAutoExtractedMemory(t *testing if len(manualRecall) != 1 || strings.Contains(manualRecall[0].Content, "不应覆盖") { t.Fatalf("manual memory should not be overwritten, got %+v", manualRecall) } - if len(extractor.candidates) != 1 || extractor.candidates[0].Ref != autoRef { - t.Fatalf("expected shortlist to target the auto-extracted memory, got %+v", extractor.candidates) + if len(extractor.candidates) != 2 { + t.Fatalf("expected existing candidates to be provided, got %+v", extractor.candidates) } } @@ -352,10 +341,6 @@ func TestAutoExtractorSemanticCreateStillUsesExactDedup(t *testing.T) { } extractor := &stubDecisionMemoExtractor{ - extractEntries: []Entry{ - {Type: TypeUser, Title: "中文回复", Content: "用户偏好中文回复。"}, - {Type: TypeProject, Title: "新事实", Content: "项目需要语义去重。"}, - }, decisions: []ExtractionDecision{ {Action: ExtractionActionCreate, Entry: Entry{Type: TypeUser, Title: "中文回复", Content: "用户偏好中文回复。"}}, {Action: ExtractionActionCreate, Entry: Entry{Type: TypeProject, Title: "新事实", Content: "项目需要语义去重。"}}, @@ -672,4 +657,4 @@ func waitFor(t *testing.T, timeout time.Duration, fn func() bool) { time.Sleep(10 * time.Millisecond) } t.Fatal("condition not met before timeout") -} +} \ No newline at end of file diff --git a/internal/memo/llm_extractor.go b/internal/memo/llm_extractor.go index ba239fe5..06769c34 100644 --- a/internal/memo/llm_extractor.go +++ b/internal/memo/llm_extractor.go @@ -1,4 +1,4 @@ -package memo +package memo import ( "context" @@ -44,6 +44,25 @@ func NewLLMExtractor(generator TextGenerator) *LLMExtractor { // Extract 从当前 run 对话中提取可跨会话持久化的新增记忆条目。 func (e *LLMExtractor) Extract(ctx context.Context, messages []providertypes.Message) ([]Entry, error) { + decisions, err := e.ExtractDecisions(ctx, messages, nil) + if err != nil { + return nil, err + } + entries := make([]Entry, 0, len(decisions)) + for _, decision := range decisions { + if decision.Action == ExtractionActionCreate { + entries = append(entries, decision.Entry) + } + } + return entries, nil +} + +// ExtractDecisions 从当前 run 对话中提取记忆,并结合既有记忆输出新增、合并或跳过决策。 +func (e *LLMExtractor) ExtractDecisions( + ctx context.Context, + messages []providertypes.Message, + existing []ExtractionCandidate, +) ([]ExtractionDecision, error) { if err := ctx.Err(); err != nil { return nil, err } @@ -56,7 +75,7 @@ func (e *LLMExtractor) Extract(ctx context.Context, messages []providertypes.Mes return nil, nil } - response, err := e.generator.Generate(ctx, buildExtractionPrompt(e.now()), runMessages) + response, err := e.generator.Generate(ctx, buildExtractionPrompt(e.now(), existing), runMessages) if err != nil { return nil, err } @@ -74,15 +93,15 @@ func (e *LLMExtractor) Extract(ctx context.Context, messages []providertypes.Mes return nil, fmt.Errorf("memo: parse extraction response: %w", err) } - entries := make([]Entry, 0, len(extracted)) + decisions := make([]ExtractionDecision, 0, len(extracted)) for _, item := range extracted { - entry, ok := toMemoEntry(item) + decision, ok := toExtractionDecision(item) if !ok { continue } - entries = append(entries, entry) + decisions = append(decisions, decision) } - return entries, nil + return decisions, nil } // ResolveDecision 结合单条候选记忆与 shortlist,解析 create/update/skip 决策。 @@ -122,15 +141,21 @@ func (e *LLMExtractor) ResolveDecision( return decision, nil } -// buildExtractionPrompt 构造仅负责“当前 run 提取”的 system prompt。 -func buildExtractionPrompt(now time.Time) string { +// buildExtractionPrompt 构造记忆提取专用的 system prompt。 +func buildExtractionPrompt(now time.Time, existing []ExtractionCandidate) string { currentDate := now.In(time.Local).Format("2006-01-02") + existingJSON := "[]" + if len(existing) > 0 { + if data, err := json.Marshal(existing); err == nil { + existingJSON = string(data) + } + } return strings.TrimSpace(fmt.Sprintf(` 你是一个记忆提取助手(memory extraction assistant)。 -分析当前 run 对话中值得跨会话持久记住的信息,并返回严格 JSON 数组。 +分析当前 run 对话中值得跨会话持久记住的信息,并结合既有记忆完成语义去重,返回严格 JSON 数组。 当前本地日期:%s -如果对话中出现“今天、明天、下周二”等相对日期,必须先转换为绝对日期再写入 content。 +如果对话中出现"今天、明天、下周二"等相对日期,必须先转换为绝对日期再写入 content。 只允许以下四种 type: - user: 用户偏好、习惯、背景、专长 - feedback: 用户对 Agent 做法的纠正、要求、确认过的工作方式 @@ -142,11 +167,18 @@ func buildExtractionPrompt(now time.Time) string { 2. 不要提取通用编程知识、代码结构、文件路径、Git 历史。 3. 每条记忆必须具体、可操作。 4. 没有值得记住的信息时,返回 []。 -5. 输出必须是 JSON 数组,不要输出任何额外解释。 +5. 如果新信息与既有记忆语义相同或只是轻微改写,输出 action="skip"。 +6. 如果新信息能补充或修正既有 source="extractor_auto" 的记忆,输出 action="update" 并填写目标 ref。 +7. 不允许 update source 不是 "extractor_auto" 的既有记忆;这类相近内容只能 skip。 +8. 如果是全新的可持久化信息,输出 action="create"。 +9. 输出必须是 JSON 数组,不要输出任何额外解释。 + +既有记忆候选(JSON): +%s 输出格式: -[{"type":"user","title":"...","content":"...","keywords":["..."]}] -`, currentDate)) +[{"action":"create","type":"user","title":"...","content":"...","keywords":["..."]},{"action":"update","ref":"project:p.md","title":"...","content":"...","keywords":["..."]},{"action":"skip","ref":"user:u.md"}] +`, currentDate, existingJSON)) } // buildResolutionPrompt 构造单条候选记忆的去重决策提示。 @@ -413,4 +445,4 @@ func extractJSONObject(text string) (string, error) { } return "", ErrExtractionIncompleteJSONArray -} +} \ No newline at end of file diff --git a/internal/memo/llm_extractor_test.go b/internal/memo/llm_extractor_test.go index 987c59fb..593bc57a 100644 --- a/internal/memo/llm_extractor_test.go +++ b/internal/memo/llm_extractor_test.go @@ -1,4 +1,4 @@ -package memo +package memo import ( "context" @@ -319,3 +319,111 @@ func TestJSONPayloadExtractors(t *testing.T) { t.Fatalf("expected incomplete object error, got %v", err) } } + +// TestLLMExtractorExtractInvalidJSON 验证无效 JSON 会返回错误。 +func TestLLMExtractorExtractInvalidJSON(t *testing.T) { + extractor := NewLLMExtractor(&stubTextGenerator{response: `[{invalid json}]`}) + + _, err := extractor.Extract(context.Background(), []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("记住这个。")}}, + }) + if err == nil { + t.Fatal("expected invalid JSON error") + } +} + +// TestLLMExtractorExtractToleratesWrappedJSON 验证 JSON 前后噪声不会影响解析。 +func TestLLMExtractorExtractToleratesWrappedJSON(t *testing.T) { + extractor := NewLLMExtractor(&stubTextGenerator{ + response: "分析如下:\n[{\"type\":\"feedback\",\"title\":\"以后先跑测试\",\"content\":\"用户要求修改后先跑测试。\"}]\n以上完毕。", + }) + + entries, err := extractor.Extract(context.Background(), []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("以后改完先跑测试。")}}, + }) + if err != nil { + t.Fatalf("Extract() error = %v", err) + } + if len(entries) != 1 || entries[0].Type != TypeFeedback { + t.Fatalf("entries = %#v", entries) + } +} + +// TestLLMExtractorExtractFiltersInvalidEntries 验证非法类型和空字段会被过滤。 +func TestLLMExtractorExtractFiltersInvalidEntries(t *testing.T) { + extractor := NewLLMExtractor(&stubTextGenerator{ + response: `[ + {"type":"invalid","title":"bad","content":"bad"}, + {"type":"project","title":" ","content":"missing title"}, + {"type":"reference","title":"文档入口","content":"查看 docs/runtime-provider-event-flow.md"} + ]`, + }) + + entries, err := extractor.Extract(context.Background(), []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("参考文档在 docs/runtime-provider-event-flow.md。")}}, + }) + if err != nil { + t.Fatalf("Extract() error = %v", err) + } + if len(entries) != 1 || entries[0].Type != TypeReference { + t.Fatalf("entries = %#v", entries) + } +} + +// TestLLMExtractorExtractCancelledContext 验证已取消上下文会中止提取。 +func TestLLMExtractorExtractCancelledContext(t *testing.T) { + generator := &stubTextGenerator{response: `[]`} + extractor := NewLLMExtractor(generator) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := extractor.Extract(ctx, []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("记住这个。")}}, + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Extract() error = %v, want context.Canceled", err) + } + if generator.calls != 0 { + t.Fatalf("Generate() calls = %d, want 0", generator.calls) + } +} + +// TestLLMExtractorExtractDecisionsIncludesExistingCandidates 验证去重决策阶段会传递既有记忆快照。 +func TestLLMExtractorExtractDecisionsIncludesExistingCandidates(t *testing.T) { + generator := &stubTextGenerator{ + response: `[{"action":"skip","ref":"user:u.md"},{"action":"update","ref":"project:p.md","title":"测试策略","content":"用户要求修改后先跑相关测试。","keywords":["test"]}]`, + } + extractor := NewLLMExtractor(generator) + + decisions, err := extractor.ExtractDecisions( + context.Background(), + []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("以后改完先跑相关测试。")}}, + }, + []ExtractionCandidate{ + { + Ref: "project:p.md", + Scope: ScopeProject, + Type: TypeFeedback, + Source: SourceAutoExtract, + Title: "测试策略", + Content: "用户要求修改后先跑测试。", + }, + }, + ) + if err != nil { + t.Fatalf("ExtractDecisions() error = %v", err) + } + if len(decisions) != 2 { + t.Fatalf("len(decisions) = %d, want 2", len(decisions)) + } + if decisions[0].Action != ExtractionActionSkip || decisions[0].Ref != "user:u.md" { + t.Fatalf("unexpected skip decision: %+v", decisions[0]) + } + if decisions[1].Action != ExtractionActionUpdate || decisions[1].Ref != "project:p.md" { + t.Fatalf("unexpected update decision: %+v", decisions[1]) + } + if !strings.Contains(generator.prompt, `"ref":"project:p.md"`) { + t.Fatalf("prompt should include existing memory candidates, got %q", generator.prompt) + } +} \ No newline at end of file diff --git a/internal/memo/types.go b/internal/memo/types.go index 04d2d084..7fc2e808 100644 --- a/internal/memo/types.go +++ b/internal/memo/types.go @@ -143,6 +143,15 @@ type DecisionResolver interface { ) (ExtractionDecision, error) } +// DecisionExtractor 定义带既有记忆快照的语义提取能力。 +type DecisionExtractor interface { + ExtractDecisions( + ctx context.Context, + messages []providertypes.Message, + existing []ExtractionCandidate, + ) ([]ExtractionDecision, error) +} + // TextGenerator 定义调用 LLM 生成文本的最小能力,用于记忆提取。 // 该接口隔离 provider 细节,避免 memo 包直接依赖 provider 基础设施。 type TextGenerator interface { diff --git a/internal/tools/codebase/read.go b/internal/tools/codebase/read.go index f76476e1..0a6db819 100644 --- a/internal/tools/codebase/read.go +++ b/internal/tools/codebase/read.go @@ -50,7 +50,7 @@ func (t *ReadTool) Schema() map[string]any { } func (t *ReadTool) MicroCompactPolicy() tools.MicroCompactPolicy { - return tools.MicroCompactPolicyPreserveHistory + return tools.MicroCompactPolicyCompact } func (t *ReadTool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) { diff --git a/internal/tools/codebase/read_test.go b/internal/tools/codebase/read_test.go index ba29f03b..4a7b20f0 100644 --- a/internal/tools/codebase/read_test.go +++ b/internal/tools/codebase/read_test.go @@ -32,8 +32,8 @@ func TestReadToolMetadata(t *testing.T) { if _, hasPath := props["path"]; !hasPath { t.Fatalf("Schema should have path property") } - if tool.MicroCompactPolicy() != tools.MicroCompactPolicyPreserveHistory { - t.Fatalf("MicroCompactPolicy() = %v, want PreserveHistory", tool.MicroCompactPolicy()) + if tool.MicroCompactPolicy() != tools.MicroCompactPolicyCompact { + t.Fatalf("MicroCompactPolicy() = %v, want Compact", tool.MicroCompactPolicy()) } } diff --git a/internal/tools/format.go b/internal/tools/format.go index 8cebb0b9..d9be2388 100644 --- a/internal/tools/format.go +++ b/internal/tools/format.go @@ -41,7 +41,9 @@ var projectedToolMetadataAllowlist = map[string]struct{}{ "returned_count": {}, "root": {}, "search_length": {}, + "source_path": {}, "status_code": {}, + "destination_path": {}, "tool_name": {}, "truncated": {}, "workdir": {}, diff --git a/internal/tools/format_test.go b/internal/tools/format_test.go index fb21c67e..20b84468 100644 --- a/internal/tools/format_test.go +++ b/internal/tools/format_test.go @@ -250,6 +250,27 @@ func TestSanitizeToolMetadata(t *testing.T) { } }, }, + { + name: "keeps copy and move path metadata but drops path arrays", + tool: "filesystem_move_file", + input: map[string]any{ + "source_path": "/repo/package.json", + "destination_path": "/repo/pkg.json", + "paths": []string{"/repo/package.json", "/repo/pkg.json"}, + }, + assert: func(t *testing.T, got map[string]string) { + t.Helper() + if got["source_path"] != "/repo/package.json" { + t.Fatalf("expected source_path to be preserved, got %#v", got) + } + if got["destination_path"] != "/repo/pkg.json" { + t.Fatalf("expected destination_path to be preserved, got %#v", got) + } + if got["paths"] != "" { + t.Fatalf("expected array metadata to be dropped, got %#v", got) + } + }, + }, } for _, tt := range tests { diff --git a/internal/tools/micro_compact_summarizer_test.go b/internal/tools/micro_compact_summarizer_test.go index d01901e6..bf3cee47 100644 --- a/internal/tools/micro_compact_summarizer_test.go +++ b/internal/tools/micro_compact_summarizer_test.go @@ -276,7 +276,7 @@ func TestRegisterBuiltinSummarizers(t *testing.T) { RegisterBuiltinSummarizers(registry) toolNames := []string{ - ToolNameBash, ToolNameFilesystemReadFile, ToolNameFilesystemWriteFile, + ToolNameBash, ToolNameFilesystemReadFile, ToolNameCodebaseRead, ToolNameFilesystemWriteFile, ToolNameFilesystemEdit, ToolNameFilesystemGrep, ToolNameFilesystemGlob, ToolNameWebFetch, } diff --git a/internal/tools/micro_compact_summarizers_builtin.go b/internal/tools/micro_compact_summarizers_builtin.go index d18481ae..de8c4886 100644 --- a/internal/tools/micro_compact_summarizers_builtin.go +++ b/internal/tools/micro_compact_summarizers_builtin.go @@ -14,6 +14,7 @@ type builtinSummarizerRegistration struct { var builtinSummarizers = []builtinSummarizerRegistration{ {toolName: ToolNameBash, summarizer: bashSummarizer}, {toolName: ToolNameFilesystemReadFile, summarizer: readFileSummarizer}, + {toolName: ToolNameCodebaseRead, summarizer: readFileSummarizer}, {toolName: ToolNameFilesystemWriteFile, summarizer: writeFileSummarizer}, {toolName: ToolNameFilesystemEdit, summarizer: editSummarizer}, {toolName: ToolNameFilesystemGrep, summarizer: grepSummarizer}, diff --git a/internal/tools/spawnsubagent/tool.go b/internal/tools/spawnsubagent/tool.go index 2394b218..95c07335 100644 --- a/internal/tools/spawnsubagent/tool.go +++ b/internal/tools/spawnsubagent/tool.go @@ -113,9 +113,9 @@ func (t *Tool) Schema() map[string]any { } } -// MicroCompactPolicy 声明 spawn_subagent 结果默认参与 micro compact。 +// MicroCompactPolicy 保留子代理结果,避免短期压缩时丢失分析链路与结论。 func (t *Tool) MicroCompactPolicy() tools.MicroCompactPolicy { - return tools.MicroCompactPolicyCompact + return tools.MicroCompactPolicyPreserveHistory } // Execute 解析入参后执行 inline 模式。 diff --git a/internal/tools/spawnsubagent/tool_test.go b/internal/tools/spawnsubagent/tool_test.go index 3e691536..c06c1b1b 100644 --- a/internal/tools/spawnsubagent/tool_test.go +++ b/internal/tools/spawnsubagent/tool_test.go @@ -38,8 +38,8 @@ func TestToolMetadata(t *testing.T) { if strings.TrimSpace(tool.Description()) == "" { t.Fatalf("Description() should not be empty") } - if tool.MicroCompactPolicy() != tools.MicroCompactPolicyCompact { - t.Fatalf("MicroCompactPolicy() = %q, want compact", tool.MicroCompactPolicy()) + if tool.MicroCompactPolicy() != tools.MicroCompactPolicyPreserveHistory { + t.Fatalf("MicroCompactPolicy() = %q, want %q", tool.MicroCompactPolicy(), tools.MicroCompactPolicyPreserveHistory) } schema := tool.Schema() properties, ok := schema["properties"].(map[string]any)