From e480e64b442c91595d3853396816beb0bf93d087 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Thu, 16 Apr 2026 22:58:07 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(context):=20=E5=BE=AE=E5=8E=8B?= =?UTF-8?q?=E7=BC=A9=E5=BC=95=E5=85=A5=E6=8C=89=E5=B7=A5=E5=85=B7=E5=B7=AE?= =?UTF-8?q?=E5=BC=82=E5=8C=96=E7=9A=84=E9=87=8D=E8=A6=81=E6=80=A7=E6=84=9F?= =?UTF-8?q?=E7=9F=A5=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 旧工具结果在 micro-compact 时统一被替换为"[Old tool result content cleared]", 丢失关键调试上下文(如 bash 错误输出、read_file 文件路径、grep 匹配结果)。 引入 ContentSummarizer 机制,为每个内置工具注册约 200 字符的摘要策略: - bash: 保留退出状态 + 末尾 5 行 + 工作目录 - read_file: 保留文件路径 + 行数 + 首尾行片段 - write_file: 保留文件路径与写入字节数 - edit: 保留编辑路径与替换范围 - grep: 保留搜索根目录 + 匹配计数 + 前几个文件名 - glob: 保留匹配计数与前几个文件名 - webfetch: 保留 URL 与截断标记 无 summarizer 的工具维持原有清除行为,完全向后兼容。 --- internal/app/bootstrap.go | 7 +- internal/context/builder.go | 42 ++- internal/context/microcompact.go | 49 ++- .../context/microcompact_summarizer_test.go | 271 ++++++++++++++++ internal/context/microcompact_test.go | 10 +- internal/context/types.go | 5 + .../runtime_remaining_branches_test.go | 4 + internal/runtime/runtime_test.go | 4 + internal/subagent/factory.go | 1 - internal/tools/manager.go | 16 + internal/tools/micro_compact_summarizer.go | 6 + .../tools/micro_compact_summarizer_test.go | 302 ++++++++++++++++++ .../micro_compact_summarizers_builtin.go | 199 ++++++++++++ internal/tools/registry.go | 39 ++- 14 files changed, 922 insertions(+), 33 deletions(-) create mode 100644 internal/context/microcompact_summarizer_test.go create mode 100644 internal/tools/micro_compact_summarizer.go create mode 100644 internal/tools/micro_compact_summarizer_test.go create mode 100644 internal/tools/micro_compact_summarizers_builtin.go diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index afa39fff..3738dc08 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -146,7 +146,10 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er // 这意味着所有会话都归属到启动时指定的项目目录下,运行时不会因配置变更而迁移存储位置。 sessionStore := agentsession.NewStore(loader.BaseDir(), cfg.Workdir) - var contextBuilder agentcontext.Builder = agentcontext.NewBuilderWithToolPolicies(toolRegistry) + // 注册内置工具的内容摘要器,使 micro-compact 在清理旧工具结果时保留关键上下文。 + tools.RegisterBuiltinSummarizers(toolRegistry) + + var contextBuilder agentcontext.Builder = agentcontext.NewBuilderWithToolPoliciesAndSummarizers(toolRegistry, toolRegistry) var memoSvc *memo.Service if cfg.Memo.Enabled { memoStore := memo.NewFileStore(loader.BaseDir(), cfg.Workdir) @@ -155,7 +158,7 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er if invalidator, ok := memoSource.(interface{ InvalidateCache() }); ok { sourceInvl = invalidator.InvalidateCache } - contextBuilder = agentcontext.NewBuilderWithMemo(toolRegistry, memoSource) + contextBuilder = agentcontext.NewBuilderWithMemoAndSummarizers(toolRegistry, toolRegistry, memoSource) memoSvc = memo.NewService(memoStore, nil, cfg.Memo, sourceInvl) toolRegistry.Register(memotool.NewRememberTool(memoSvc)) toolRegistry.Register(memotool.NewRecallTool(memoSvc)) diff --git a/internal/context/builder.go b/internal/context/builder.go index 88369966..c53a50e3 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -9,9 +9,10 @@ import ( // DefaultBuilder preserves the current runtime context-building behavior. type DefaultBuilder struct { - promptSources []promptSectionSource - trimPolicy messageTrimPolicy - microCompactPolicies MicroCompactPolicySource + promptSources []promptSectionSource + trimPolicy messageTrimPolicy + microCompactPolicies MicroCompactPolicySource + microCompactSummarizers MicroCompactSummarizerSource } // NewBuilder returns the default context builder implementation. @@ -36,9 +37,32 @@ func NewBuilderWithToolPolicies(policies MicroCompactPolicySource) Builder { } } +// NewBuilderWithToolPoliciesAndSummarizers 返回带工具策略与内容摘要器的上下文构建器。 +func NewBuilderWithToolPoliciesAndSummarizers(policies MicroCompactPolicySource, summarizers MicroCompactSummarizerSource) Builder { + systemSource := &systemStateSource{gitRunner: runGitCommand} + return &DefaultBuilder{ + promptSources: []promptSectionSource{ + corePromptSource{}, + &projectRulesSource{}, + taskStateSource{}, + todosSource{}, + skillPromptSource{}, + systemSource, + }, + trimPolicy: spanMessageTrimPolicy{}, + microCompactPolicies: policies, + microCompactSummarizers: summarizers, + } +} + // NewBuilderWithMemo 返回带记忆注入能力的上下文构建器。 // memoSource 为 nil 时等价于 NewBuilderWithToolPolicies。 func NewBuilderWithMemo(policies MicroCompactPolicySource, memoSource SectionSource) Builder { + return NewBuilderWithMemoAndSummarizers(policies, nil, memoSource) +} + +// NewBuilderWithMemoAndSummarizers 返回带记忆注入与内容摘要器的上下文构建器。 +func NewBuilderWithMemoAndSummarizers(policies MicroCompactPolicySource, summarizers MicroCompactSummarizerSource, memoSource SectionSource) Builder { systemSource := &systemStateSource{gitRunner: runGitCommand} sources := []promptSectionSource{ corePromptSource{}, @@ -52,9 +76,10 @@ func NewBuilderWithMemo(policies MicroCompactPolicySource, memoSource SectionSou } sources = append(sources, systemSource) return &DefaultBuilder{ - promptSources: sources, - trimPolicy: spanMessageTrimPolicy{}, - microCompactPolicies: policies, + promptSources: sources, + trimPolicy: spanMessageTrimPolicy{}, + microCompactPolicies: policies, + microCompactSummarizers: summarizers, } } @@ -83,7 +108,7 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu return BuildResult{ SystemPrompt: composeSystemPrompt(sections...), - Messages: applyReadTimeContextProjection(trimPolicy.Trim(input.Messages, input.Compact), input.TaskState, input.Compact, b.microCompactPolicies), + Messages: applyReadTimeContextProjection(trimPolicy.Trim(input.Messages, input.Compact), input.TaskState, input.Compact, b.microCompactPolicies, b.microCompactSummarizers), AutoCompactSuggested: shouldAutoCompact, }, nil } @@ -94,12 +119,13 @@ func applyReadTimeContextProjection( taskState agentsession.TaskState, options CompactOptions, policies MicroCompactPolicySource, + summarizers MicroCompactSummarizerSource, ) []providertypes.Message { var projected []providertypes.Message if options.DisableMicroCompact || !taskState.Established() { projected = cloneContextMessages(messages) } else { - projected = microCompactMessagesWithPolicies(messages, policies, options.MicroCompactRetainedToolSpans) + projected = microCompactMessagesWithPolicies(messages, policies, options.MicroCompactRetainedToolSpans, summarizers) } return ProjectToolMessagesForModel(projected) } diff --git a/internal/context/microcompact.go b/internal/context/microcompact.go index 4ce51368..18fa4b81 100644 --- a/internal/context/microcompact.go +++ b/internal/context/microcompact.go @@ -17,11 +17,11 @@ const ( // microCompactMessages 对裁剪后的消息做只读投影式微压缩,仅清理旧工具结果内容。 func microCompactMessages(messages []providertypes.Message) []providertypes.Message { - return microCompactMessagesWithPolicies(messages, nil, 0) + return microCompactMessagesWithPolicies(messages, nil, 0, nil) } // microCompactMessagesWithPolicies 按工具策略对裁剪后的消息做只读投影式微压缩。 -func microCompactMessagesWithPolicies(messages []providertypes.Message, policies MicroCompactPolicySource, retainedToolSpans int) []providertypes.Message { +func microCompactMessagesWithPolicies(messages []providertypes.Message, policies MicroCompactPolicySource, retainedToolSpans int, summarizers MicroCompactSummarizerSource) []providertypes.Message { if retainedToolSpans <= 0 { retainedToolSpans = defaultMicroCompactRetainedToolSpans } @@ -44,7 +44,7 @@ func microCompactMessagesWithPolicies(messages []providertypes.Message, policies continue } - compactableIDs := compactableToolCallIDs(cloned[span.Start].ToolCalls, policies) + compactableIDs, toolNames := compactableToolCallIDs(cloned[span.Start].ToolCalls, policies) if len(compactableIDs) == 0 { continue } @@ -58,7 +58,9 @@ func microCompactMessagesWithPolicies(messages []providertypes.Message, policies for messageIndex := span.Start + 1; messageIndex < span.End; messageIndex++ { if shouldClearToolMessage(cloned[messageIndex], compactableIDs) { - cloned[messageIndex].Content = microCompactClearedMessage + cloned[messageIndex].Content = summarizeOrClear( + cloned[messageIndex], toolNames, summarizers, + ) } } } @@ -96,13 +98,14 @@ func isToolCallSpan(messages []providertypes.Message, span internalcompact.Messa return message.Role == providertypes.RoleAssistant && len(message.ToolCalls) > 0 } -// compactableToolCallIDs 返回 assistant tool call 中可参与微压缩的调用 ID 集合。 -func compactableToolCallIDs(calls []providertypes.ToolCall, policies MicroCompactPolicySource) map[string]struct{} { +// compactableToolCallIDs 返回 assistant tool call 中可参与微压缩的调用 ID 集合及对应的工具名映射。 +func compactableToolCallIDs(calls []providertypes.ToolCall, policies MicroCompactPolicySource) (map[string]struct{}, map[string]string) { if len(calls) == 0 { - return nil + return nil, nil } ids := make(map[string]struct{}, len(calls)) + toolNames := make(map[string]string, len(calls)) for _, call := range calls { toolName := strings.TrimSpace(call.Name) if !toolParticipatesInMicroCompact(toolName, policies) { @@ -113,11 +116,12 @@ func compactableToolCallIDs(calls []providertypes.ToolCall, policies MicroCompac continue } ids[callID] = struct{}{} + toolNames[callID] = toolName } if len(ids) == 0 { - return nil + return nil, nil } - return ids + return ids, toolNames } // toolParticipatesInMicroCompact 判断工具是否应参与 micro compact;未知工具默认视为可压缩。 @@ -153,3 +157,30 @@ func shouldClearToolMessage(message providertypes.Message, compactableIDs map[st content := strings.TrimSpace(message.Content) return content != "" && content != microCompactClearedMessage } + +// summarizeOrClear 为单条可压缩工具消息生成摘要或回退到默认清除占位。 +func summarizeOrClear( + message providertypes.Message, + toolNames map[string]string, + summarizers MicroCompactSummarizerSource, +) string { + if summarizers == nil { + return microCompactClearedMessage + } + + toolName, ok := toolNames[strings.TrimSpace(message.ToolCallID)] + if !ok { + return microCompactClearedMessage + } + + summarizer := summarizers.MicroCompactSummarizer(toolName) + if summarizer == nil { + return microCompactClearedMessage + } + + summary := summarizer(message.Content, message.ToolMetadata, message.IsError) + if summary == "" { + return microCompactClearedMessage + } + return summary +} diff --git a/internal/context/microcompact_summarizer_test.go b/internal/context/microcompact_summarizer_test.go new file mode 100644 index 00000000..0eb46fd2 --- /dev/null +++ b/internal/context/microcompact_summarizer_test.go @@ -0,0 +1,271 @@ +package context + +import ( + "strings" + "testing" + + providertypes "neo-code/internal/provider/types" + "neo-code/internal/tools" +) + +// stubMicroCompactSummarizerSource 实现 MicroCompactSummarizerSource,用于测试。 +type stubMicroCompactSummarizerSource map[string]tools.ContentSummarizer + +func (s stubMicroCompactSummarizerSource) MicroCompactSummarizer(name string) tools.ContentSummarizer { + return s[name] +} + +// TestMicroCompactWithSummarizerProducesSummary 验证注册 summarizer 的工具生成摘要而非清除占位。 +func TestMicroCompactWithSummarizerProducesSummary(t *testing.T) { + t.Parallel() + + bashSummarizer := func(content string, metadata map[string]string, isError bool) string { + return "[summary] bash: " + content + } + + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older user"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-1", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "old bash result"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-2", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-3", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "latest bash result"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleAssistant, Content: "current reply"}, + } + + got := microCompactMessagesWithPolicies( + messages, + stubMicroCompactPolicySource{}, + 0, + stubMicroCompactSummarizerSource{"bash": bashSummarizer}, + ) + + if got[2].Content == microCompactClearedMessage { + t.Fatalf("expected summarized content for old bash result, got cleared placeholder") + } + if !strings.Contains(got[2].Content, "[summary] bash:") { + t.Fatalf("expected summary prefix, got %q", got[2].Content) + } + if got[4].Content != "recent bash result" { + t.Fatalf("expected recent bash result retained, got %q", got[4].Content) + } + if got[6].Content != "latest bash result" { + t.Fatalf("expected latest bash result retained, got %q", got[6].Content) + } + // 原始切片不被修改 + if messages[2].Content != "old bash result" { + t.Fatalf("expected original slice unchanged, got %q", messages[2].Content) + } +} + +// TestMicroCompactWithoutSummarizerFallsBackToClear 验证未注册 summarizer 的工具仍使用清除占位。 +func TestMicroCompactWithoutSummarizerFallsBackToClear(t *testing.T) { + t.Parallel() + + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older user"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "old read result"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-2", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-3", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "latest bash result"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + } + + // 只为 bash 注册 summarizer,read_file 没有 + got := microCompactMessagesWithPolicies( + messages, + stubMicroCompactPolicySource{}, + 0, + stubMicroCompactSummarizerSource{ + "bash": func(content string, metadata map[string]string, isError bool) string { + return "[summary] bash: " + content + }, + }, + ) + + // read_file 没有 summarizer,应回退到清除 + if got[2].Content != microCompactClearedMessage { + t.Fatalf("expected cleared placeholder for read_file without summarizer, got %q", got[2].Content) + } +} + +// TestMicroCompactMixedSpanWithSummarizer 验证混合工具 span 中部分有摘要、部分清除。 +func TestMicroCompactMixedSpanWithSummarizer(t *testing.T) { + t.Parallel() + + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older user"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-1", Name: "bash", Arguments: "{}"}, + {ID: "call-2", Name: "filesystem_read_file", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "bash output"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "read output"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-3", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "recent bash"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-4", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-4", Content: "latest bash"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleAssistant, Content: "reply"}, + } + + got := microCompactMessagesWithPolicies( + messages, + stubMicroCompactPolicySource{}, + 0, + stubMicroCompactSummarizerSource{ + "bash": func(content string, metadata map[string]string, isError bool) string { + return "[summary] " + content + }, + }, + ) + + // call-1 bash 在旧 span,有 summarizer,应生成摘要 + if !strings.Contains(got[2].Content, "[summary]") { + t.Fatalf("expected bash summary in old span, got %q", got[2].Content) + } + // call-2 read_file 在旧 span,没有 summarizer,应清除 + if got[3].Content != microCompactClearedMessage { + t.Fatalf("expected read_file cleared in old span, got %q", got[3].Content) + } +} + +// TestMicroCompactSummarizerReturnsEmptyFallsBackToClear 验证 summarizer 返回空字符串时回退到清除。 +func TestMicroCompactSummarizerReturnsEmptyFallsBackToClear(t *testing.T) { + t.Parallel() + + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older user"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-1", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "old result"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-2", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "middle result"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-3", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "recent result"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + } + + got := microCompactMessagesWithPolicies( + messages, + stubMicroCompactPolicySource{}, + 0, + stubMicroCompactSummarizerSource{ + "bash": func(content string, metadata map[string]string, isError bool) string { + return "" // 返回空 + }, + }, + ) + + if got[2].Content != microCompactClearedMessage { + t.Fatalf("expected cleared fallback when summarizer returns empty, got %q", got[2].Content) + } +} + +// TestSummarizeOrClearWithNilSummarizers 验证 nil summarizers 回退到清除。 +func TestSummarizeOrClearWithNilSummarizers(t *testing.T) { + t.Parallel() + + got := summarizeOrClear( + providertypes.Message{Content: "test"}, + nil, + nil, + ) + if got != microCompactClearedMessage { + t.Fatalf("expected cleared message for nil summarizers, got %q", got) + } +} + +// TestSummarizeOrClearWithToolNamesLookup 验证 toolNames map 查找工具名。 +func TestSummarizeOrClearWithToolNamesLookup(t *testing.T) { + t.Parallel() + + t.Run("found", func(t *testing.T) { + toolNames := map[string]string{"call-2": "filesystem_read_file"} + got := summarizeOrClear( + providertypes.Message{ToolCallID: "call-2", Content: "content"}, + toolNames, + stubMicroCompactSummarizerSource{ + "filesystem_read_file": func(content string, metadata map[string]string, isError bool) string { + return "[summary] " + content + }, + }, + ) + if !strings.Contains(got, "[summary]") { + t.Fatalf("expected summary, got %q", got) + } + }) + + t.Run("not_found_in_tool_names", func(t *testing.T) { + toolNames := map[string]string{"call-1": "bash"} + got := summarizeOrClear( + providertypes.Message{ToolCallID: "unknown-id", Content: "content"}, + toolNames, + stubMicroCompactSummarizerSource{}, + ) + if got != microCompactClearedMessage { + t.Fatalf("expected cleared for unknown tool call id, got %q", got) + } + }) +} diff --git a/internal/context/microcompact_test.go b/internal/context/microcompact_test.go index 49b0e4de..670ec7a8 100644 --- a/internal/context/microcompact_test.go +++ b/internal/context/microcompact_test.go @@ -79,7 +79,7 @@ func TestMicroCompactMessagesHandlesEmptyAndInvalidSpanInputs(t *testing.T) { }, }, } - got := microCompactMessagesWithPolicies(assistantOnly, stubMicroCompactPolicySource{}, 0) + got := microCompactMessagesWithPolicies(assistantOnly, stubMicroCompactPolicySource{}, 0, nil) if len(got) != 1 || len(got[0].ToolCalls) != 1 { t.Fatalf("expected invalid tool call id path to keep message untouched, got %+v", got) } @@ -173,7 +173,7 @@ func TestMicroCompactMessagesKeepsPreservedToolsErrorsAndOrphans(t *testing.T) { got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ "custom_tool": tools.MicroCompactPolicyPreserveHistory, - }, 0) + }, 0, nil) if got[1].Content != "custom result" { t.Fatalf("expected preserved tool result to remain, got %q", got[1].Content) } @@ -225,7 +225,7 @@ func TestMicroCompactMessagesClearsOnlyNonPreservedResultsInMixedToolSpan(t *tes got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ "custom_tool": tools.MicroCompactPolicyPreserveHistory, - }, 0) + }, 0, nil) if got[2].Content != microCompactClearedMessage { t.Fatalf("expected default compactable tool result to be cleared, got %q", got[2].Content) } @@ -266,7 +266,7 @@ func TestMicroCompactMessagesTreatsNewToolsAsCompactableByDefault(t *testing.T) {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, } - got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 0) + got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 0, nil) if got[2].Content != microCompactClearedMessage { t.Fatalf("expected new tool result to be compacted by default, got %q", got[2].Content) } @@ -341,7 +341,7 @@ func TestMicroCompactMessagesSkipsToolMessagesWhenCompactableIDsMissing(t *testi {Role: providertypes.RoleTool, ToolCallID: "orphan", Content: "orphan result"}, } - got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 0) + got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 0, nil) if got[0].Content != "orphan result" { t.Fatalf("expected orphan tool result to remain, got %q", got[0].Content) } diff --git a/internal/context/types.go b/internal/context/types.go index 269fcf21..10877ad0 100644 --- a/internal/context/types.go +++ b/internal/context/types.go @@ -36,6 +36,11 @@ type MicroCompactPolicySource interface { MicroCompactPolicy(name string) tools.MicroCompactPolicy } +// MicroCompactSummarizerSource 定义 context 查找按工具内容摘要器的最小依赖。 +type MicroCompactSummarizerSource interface { + MicroCompactSummarizer(name string) tools.ContentSummarizer +} + // CompactOptions controls read-time compact behavior inside the context builder. type CompactOptions struct { DisableMicroCompact bool diff --git a/internal/runtime/runtime_remaining_branches_test.go b/internal/runtime/runtime_remaining_branches_test.go index 96c53dc3..5bafb463 100644 --- a/internal/runtime/runtime_remaining_branches_test.go +++ b/internal/runtime/runtime_remaining_branches_test.go @@ -35,6 +35,10 @@ func (m *callbackToolManager) MicroCompactPolicy(name string) tools.MicroCompact return tools.MicroCompactPolicyCompact } +func (m *callbackToolManager) MicroCompactSummarizer(name string) tools.ContentSummarizer { + return nil +} + func (m *callbackToolManager) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { if m.executeFn != nil { return m.executeFn(ctx, input) diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 623c2de5..18d1c686 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -380,6 +380,10 @@ func (m *stubToolManager) MicroCompactPolicy(name string) tools.MicroCompactPoli return tools.MicroCompactPolicyCompact } +func (m *stubToolManager) MicroCompactSummarizer(name string) tools.ContentSummarizer { + return nil +} + func (m *stubToolManager) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { m.mu.Lock() m.executeCalls++ diff --git a/internal/subagent/factory.go b/internal/subagent/factory.go index 3b4b19ba..77f7bf05 100644 --- a/internal/subagent/factory.go +++ b/internal/subagent/factory.go @@ -33,4 +33,3 @@ func (f *WorkerFactory) Create(role Role) (WorkerRuntime, error) { } return NewWorker(role, policy, engine) } - diff --git a/internal/tools/manager.go b/internal/tools/manager.go index 51ae622f..b714c254 100644 --- a/internal/tools/manager.go +++ b/internal/tools/manager.go @@ -21,6 +21,7 @@ type SpecListInput struct { type Manager interface { ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]providertypes.ToolSpec, error) MicroCompactPolicy(name string) MicroCompactPolicy + MicroCompactSummarizer(name string) ContentSummarizer // Execute 必须支持并发调用;runtime 可能在同一轮中并行调度多个工具调用。 Execute(ctx context.Context, input ToolCallInput) (ToolResult, error) RememberSessionDecision(sessionID string, action security.Action, scope SessionPermissionScope) error @@ -37,6 +38,10 @@ type microCompactPolicyExecutor interface { MicroCompactPolicy(name string) MicroCompactPolicy } +type microCompactSummarizerExecutor interface { + MicroCompactSummarizer(name string) ContentSummarizer +} + // WorkspaceSandbox enforces workspace-oriented constraints before execution. type WorkspaceSandbox interface { Check(ctx context.Context, action security.Action) (*security.WorkspaceExecutionPlan, error) @@ -181,6 +186,17 @@ func (m *DefaultManager) MicroCompactPolicy(name string) MicroCompactPolicy { return MicroCompactPolicyCompact } +// MicroCompactSummarizer 返回工具的内容摘要器;未注册时返回 nil。 +func (m *DefaultManager) MicroCompactSummarizer(name string) ContentSummarizer { + if m == nil || m.executor == nil { + return nil + } + if source, ok := m.executor.(microCompactSummarizerExecutor); ok { + return source.MicroCompactSummarizer(name) + } + return nil +} + // Execute runs the tool if the permission engine allows it and the sandbox // check passes. func (m *DefaultManager) Execute(ctx context.Context, input ToolCallInput) (ToolResult, error) { diff --git a/internal/tools/micro_compact_summarizer.go b/internal/tools/micro_compact_summarizer.go new file mode 100644 index 00000000..ad5a8274 --- /dev/null +++ b/internal/tools/micro_compact_summarizer.go @@ -0,0 +1,6 @@ +package tools + +// ContentSummarizer 将工具结果内容压缩为短摘要,用于 micro-compact 替换旧工具输出。 +// content 和 metadata 来自持久化后的 Message 字段,isError 标识原始工具是否报错。 +// 返回空字符串表示"无摘要,回退到默认清除行为"。 +type ContentSummarizer func(content string, metadata map[string]string, isError bool) string diff --git a/internal/tools/micro_compact_summarizer_test.go b/internal/tools/micro_compact_summarizer_test.go new file mode 100644 index 00000000..b128fe7a --- /dev/null +++ b/internal/tools/micro_compact_summarizer_test.go @@ -0,0 +1,302 @@ +package tools + +import ( + "strings" + "testing" + "unicode/utf8" +) + +// stubMetadata 快速构建测试用 metadata map。 +func stubMetadata(keyValue ...string) map[string]string { + m := make(map[string]string, len(keyValue)/2) + for i := 0; i+1 < len(keyValue); i += 2 { + m[keyValue[i]] = keyValue[i+1] + } + return m +} + +func TestBashSummarizer(t *testing.T) { + t.Parallel() + + t.Run("normal_output", func(t *testing.T) { + content := "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8" + meta := stubMetadata("workdir", "/home/user/project") + got := bashSummarizer(content, meta, false) + if !strings.Contains(got, "[exit=0]") { + t.Fatalf("expected exit=0 in summary, got %q", got) + } + if !strings.Contains(got, "workdir=/home/user/project") { + t.Fatalf("expected workdir in summary, got %q", got) + } + if !strings.Contains(got, "line8") { + t.Fatalf("expected last line preserved, got %q", got) + } + if utf8.RuneCountInString(got) > 200 { + t.Fatalf("summary exceeds 200 runes: %d", utf8.RuneCountInString(got)) + } + }) + + t.Run("error_output", func(t *testing.T) { + content := "error: command not found" + meta := stubMetadata("workdir", "/tmp") + got := bashSummarizer(content, meta, true) + if !strings.Contains(got, "[exit=non-zero]") { + t.Fatalf("expected exit=non-zero in summary, got %q", got) + } + }) + + t.Run("short_output", func(t *testing.T) { + content := "ok" + got := bashSummarizer(content, nil, false) + if !strings.Contains(got, "ok") { + t.Fatalf("expected content preserved for short output, got %q", got) + } + }) + + t.Run("empty_content", func(t *testing.T) { + got := bashSummarizer("", nil, false) + if !strings.Contains(got, "[exit=0]") { + t.Fatalf("expected summary even with empty content, got %q", got) + } + }) +} + +func TestReadFileSummarizer(t *testing.T) { + t.Parallel() + + t.Run("normal_file", func(t *testing.T) { + content := "package main\n\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n" + meta := stubMetadata("path", "/home/user/main.go") + got := readFileSummarizer(content, meta, false) + if !strings.Contains(got, "/home/user/main.go") { + t.Fatalf("expected path in summary, got %q", got) + } + if !strings.Contains(got, "lines=") { + t.Fatalf("expected lines count in summary, got %q", got) + } + if !strings.Contains(got, "first=package main") { + t.Fatalf("expected first line in summary, got %q", got) + } + if utf8.RuneCountInString(got) > 200 { + t.Fatalf("summary exceeds 200 runes: %d", utf8.RuneCountInString(got)) + } + }) + + t.Run("missing_path", func(t *testing.T) { + got := readFileSummarizer("content", nil, false) + if got != "" { + t.Fatalf("expected empty string for missing path, got %q", got) + } + }) +} + +func TestWriteFileSummarizer(t *testing.T) { + t.Parallel() + + t.Run("normal", func(t *testing.T) { + meta := stubMetadata("path", "/home/user/test.go", "bytes", "1024") + got := writeFileSummarizer("", meta, false) + if !strings.Contains(got, "/home/user/test.go") { + t.Fatalf("expected path in summary, got %q", got) + } + if !strings.Contains(got, "1024 bytes") { + t.Fatalf("expected bytes in summary, got %q", got) + } + }) + + t.Run("missing_path", func(t *testing.T) { + got := writeFileSummarizer("", stubMetadata("bytes", "100"), false) + if got != "" { + t.Fatalf("expected empty for missing path, got %q", got) + } + }) +} + +func TestEditSummarizer(t *testing.T) { + t.Parallel() + + t.Run("with_relative_path", func(t *testing.T) { + meta := stubMetadata("relative_path", "src/main.go", "path", "/abs/src/main.go", "search_length", "50", "replacement_length", "60") + got := editSummarizer("", meta, false) + if !strings.Contains(got, "src/main.go") { + t.Fatalf("expected relative_path preferred, got %q", got) + } + if !strings.Contains(got, "search=50") { + t.Fatalf("expected search_length, got %q", got) + } + }) + + t.Run("fallback_to_abs_path", func(t *testing.T) { + meta := stubMetadata("path", "/abs/src/main.go", "search_length", "10", "replacement_length", "20") + got := editSummarizer("", meta, false) + if !strings.Contains(got, "/abs/src/main.go") { + t.Fatalf("expected abs path fallback, got %q", got) + } + }) + + t.Run("missing_path", func(t *testing.T) { + got := editSummarizer("", stubMetadata("search_length", "10"), false) + if got != "" { + t.Fatalf("expected empty for missing path, got %q", got) + } + }) +} + +func TestGrepSummarizer(t *testing.T) { + t.Parallel() + + t.Run("with_matches", func(t *testing.T) { + content := "src/a.go:10:match1\nsrc/b.go:20:match2\nsrc/c.go:30:match3\nsrc/d.go:40:match4" + meta := stubMetadata("root", "/home/user", "matched_files", "4", "matched_lines", "4") + got := grepSummarizer(content, meta, false) + if !strings.Contains(got, "root=/home/user") { + t.Fatalf("expected root in summary, got %q", got) + } + if !strings.Contains(got, "files=4") { + t.Fatalf("expected files count, got %q", got) + } + if utf8.RuneCountInString(got) > 200 { + t.Fatalf("summary exceeds 200 runes: %d", utf8.RuneCountInString(got)) + } + }) + + t.Run("empty_content", func(t *testing.T) { + meta := stubMetadata("root", "/home", "matched_files", "0", "matched_lines", "0") + got := grepSummarizer("", meta, false) + if !strings.Contains(got, "files=0") { + t.Fatalf("expected files count, got %q", got) + } + }) +} + +func TestGlobSummarizer(t *testing.T) { + t.Parallel() + + t.Run("with_files", func(t *testing.T) { + content := "src/a.go\nsrc/b.go\nsrc/c.go\nsrc/d.go" + meta := stubMetadata("count", "4") + got := globSummarizer(content, meta, false) + if !strings.Contains(got, "4 files") { + t.Fatalf("expected file count, got %q", got) + } + if utf8.RuneCountInString(got) > 200 { + t.Fatalf("summary exceeds 200 runes: %d", utf8.RuneCountInString(got)) + } + }) + + t.Run("no_matches", func(t *testing.T) { + meta := stubMetadata("count", "0") + got := globSummarizer("", meta, false) + if !strings.Contains(got, "0 files") { + t.Fatalf("expected 0 files, got %q", got) + } + }) +} + +func TestWebfetchSummarizer(t *testing.T) { + t.Parallel() + + t.Run("with_url", func(t *testing.T) { + meta := stubMetadata("url", "https://example.com/api", "truncated", "true") + got := webfetchSummarizer("", meta, false) + if !strings.Contains(got, "https://example.com/api") { + t.Fatalf("expected url in summary, got %q", got) + } + if !strings.Contains(got, "truncated=true") { + t.Fatalf("expected truncated flag, got %q", got) + } + }) + + t.Run("minimal", func(t *testing.T) { + got := webfetchSummarizer("", nil, false) + if !strings.Contains(got, "[summary] webfetch") { + t.Fatalf("expected minimal summary, got %q", got) + } + }) +} + +func TestRegisterBuiltinSummarizers(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + RegisterBuiltinSummarizers(registry) + + toolNames := []string{ + ToolNameBash, ToolNameFilesystemReadFile, ToolNameFilesystemWriteFile, + ToolNameFilesystemEdit, ToolNameFilesystemGrep, ToolNameFilesystemGlob, + ToolNameWebFetch, + } + for _, name := range toolNames { + if registry.MicroCompactSummarizer(name) == nil { + t.Errorf("expected summarizer for %q to be registered", name) + } + } + + // 不在注册列表中的工具应返回 nil + if registry.MicroCompactSummarizer("unknown_tool") != nil { + t.Fatal("expected nil for unknown tool") + } +} + +func TestRegisterSummarizer(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + + // 注册 + called := false + registry.RegisterSummarizer("test_tool", func(content string, metadata map[string]string, isError bool) string { + called = true + return "summary" + }) + + s := registry.MicroCompactSummarizer("test_tool") + if s == nil { + t.Fatal("expected summarizer to be registered") + } + result := s("content", nil, false) + if !called { + t.Fatal("expected summarizer to be called") + } + if result != "summary" { + t.Fatalf("expected 'summary', got %q", result) + } + + // 移除 + registry.RegisterSummarizer("test_tool", nil) + if registry.MicroCompactSummarizer("test_tool") != nil { + t.Fatal("expected nil after removal") + } +} + +func TestTruncateRunes(t *testing.T) { + t.Parallel() + + t.Run("short", func(t *testing.T) { + got := truncateRunes("hello", 10) + if got != "hello" { + t.Fatalf("expected unchanged, got %q", got) + } + }) + + t.Run("exact", func(t *testing.T) { + got := truncateRunes("hello", 5) + if got != "hello" { + t.Fatalf("expected unchanged, got %q", got) + } + }) + + t.Run("truncated", func(t *testing.T) { + got := truncateRunes("hello world", 5) + if got != "hello..." { + t.Fatalf("expected 'hello...', got %q", got) + } + }) + + t.Run("chinese", func(t *testing.T) { + got := truncateRunes("你好世界测试", 3) + if got != "你好世..." { + t.Fatalf("expected '你好世...', got %q", got) + } + }) +} diff --git a/internal/tools/micro_compact_summarizers_builtin.go b/internal/tools/micro_compact_summarizers_builtin.go new file mode 100644 index 00000000..2dea6aba --- /dev/null +++ b/internal/tools/micro_compact_summarizers_builtin.go @@ -0,0 +1,199 @@ +package tools + +import ( + "strconv" + "strings" + "unicode/utf8" +) + +// RegisterBuiltinSummarizers 将所有内置工具的内容摘要器注册到 Registry。 +// 应在所有工具注册完成后调用一次。 +func RegisterBuiltinSummarizers(registry *Registry) { + if registry == nil { + return + } + registry.RegisterSummarizer(ToolNameBash, bashSummarizer) + registry.RegisterSummarizer(ToolNameFilesystemReadFile, readFileSummarizer) + registry.RegisterSummarizer(ToolNameFilesystemWriteFile, writeFileSummarizer) + registry.RegisterSummarizer(ToolNameFilesystemEdit, editSummarizer) + registry.RegisterSummarizer(ToolNameFilesystemGrep, grepSummarizer) + registry.RegisterSummarizer(ToolNameFilesystemGlob, globSummarizer) + registry.RegisterSummarizer(ToolNameWebFetch, webfetchSummarizer) +} + +const summaryMaxRunes = 200 + +// bashSummarizer 保留退出状态 + 末尾若干行 + 工作目录。 +func bashSummarizer(content string, metadata map[string]string, isError bool) string { + var parts []string + + if isError { + parts = append(parts, "[exit=non-zero]") + } else { + parts = append(parts, "[exit=0]") + } + + if workdir := metadata["workdir"]; workdir != "" { + parts = append(parts, "workdir="+workdir) + } + + const tailLines = 5 + lines := strings.Split(strings.TrimSpace(content), "\n") + if len(lines) > tailLines { + body := "...(truncated)\n" + strings.Join(lines[len(lines)-tailLines:], "\n") + parts = append(parts, body) + } else if len(lines) > 0 && strings.TrimSpace(content) != "" { + parts = append(parts, content) + } + + return truncateRunes(strings.Join(parts, " "), summaryMaxRunes) +} + +// readFileSummarizer 保留文件路径 + 行数 + 首尾行片段。 +func readFileSummarizer(content string, metadata map[string]string, isError bool) string { + path := metadata["path"] + if path == "" { + return "" + } + + lines := strings.Split(content, "\n") + lineCount := len(lines) + + var parts []string + parts = append(parts, "[summary]", path, "lines="+strconv.Itoa(lineCount)) + + if len(lines) > 0 { + first := truncateRunes(strings.TrimSpace(lines[0]), 60) + if first != "" { + parts = append(parts, "first="+first) + } + } + if len(lines) > 1 { + last := truncateRunes(strings.TrimSpace(lines[len(lines)-1]), 60) + if last != "" && last != strings.TrimSpace(lines[0]) { + parts = append(parts, "last="+last) + } + } + + return truncateRunes(strings.Join(parts, " "), summaryMaxRunes) +} + +// writeFileSummarizer 保留文件路径与写入字节数。 +func writeFileSummarizer(content string, metadata map[string]string, isError bool) string { + path := metadata["path"] + if path == "" { + return "" + } + bytes := metadata["bytes"] + return "[summary] wrote " + path + " (" + bytes + " bytes)" +} + +// editSummarizer 保留编辑路径与替换范围。 +func editSummarizer(content string, metadata map[string]string, isError bool) string { + path := metadata["relative_path"] + if path == "" { + path = metadata["path"] + } + if path == "" { + return "" + } + searchLen := metadata["search_length"] + replaceLen := metadata["replacement_length"] + return "[summary] edited " + path + " (search=" + searchLen + " chars, replace=" + replaceLen + " chars)" +} + +// grepSummarizer 保留搜索根目录、匹配计数与前若干文件名。 +func grepSummarizer(content string, metadata map[string]string, isError bool) string { + var parts []string + parts = append(parts, "[summary] grep") + + if root := metadata["root"]; root != "" { + parts = append(parts, "root="+root) + } + + if matchedFiles := metadata["matched_files"]; matchedFiles != "" { + parts = append(parts, "files="+matchedFiles) + } + if matchedLines := metadata["matched_lines"]; matchedLines != "" { + parts = append(parts, "lines="+matchedLines) + } + + // 从 content 中提取前几个不重复文件名 + contentLines := strings.Split(strings.TrimSpace(content), "\n") + fileSet := make(map[string]struct{}) + var fileNames []string + for _, line := range contentLines { + if len(fileSet) >= 3 { + break + } + idx := strings.Index(line, ":") + if idx > 0 { + f := line[:idx] + if _, ok := fileSet[f]; !ok { + fileSet[f] = struct{}{} + fileNames = append(fileNames, f) + } + } + } + if len(fileNames) > 0 { + parts = append(parts, "matches="+strings.Join(fileNames, ", ")) + } + + return truncateRunes(strings.Join(parts, " "), summaryMaxRunes) +} + +// globSummarizer 保留匹配计数与前若干文件名。 +func globSummarizer(content string, metadata map[string]string, isError bool) string { + count := metadata["count"] + if count == "" { + count = "?" + } + + contentLines := strings.Split(strings.TrimSpace(content), "\n") + const previewLimit = 3 + var preview []string + for i, line := range contentLines { + if i >= previewLimit { + break + } + trimmed := strings.TrimSpace(line) + if trimmed != "" { + preview = append(preview, trimmed) + } + } + + var parts []string + parts = append(parts, "[summary] glob", count+" files") + if len(preview) > 0 { + parts = append(parts, strings.Join(preview, ", ")) + } + + return truncateRunes(strings.Join(parts, " "), summaryMaxRunes) +} + +// webfetchSummarizer 保留 URL、截断标记等持久化元数据。 +func webfetchSummarizer(content string, metadata map[string]string, isError bool) string { + var parts []string + parts = append(parts, "[summary] webfetch") + + if url := metadata["url"]; url != "" { + parts = append(parts, url) + } + if truncated := metadata["truncated"]; truncated == "true" { + parts = append(parts, "truncated=true") + } + + return truncateRunes(strings.Join(parts, " "), summaryMaxRunes) +} + +// truncateRunes 按 rune 数量截断字符串,超出时追加 "..."。 +func truncateRunes(text string, maxRunes int) string { + if maxRunes <= 0 || text == "" { + return text + } + if utf8.RuneCountInString(text) <= maxRunes { + return text + } + runes := []rune(text) + return string(runes[:maxRunes]) + "..." +} diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 998ba939..dd7e4331 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -12,18 +12,20 @@ import ( ) type Registry struct { - tools map[string]Tool - microCompactPolicies map[string]MicroCompactPolicy - mcpRegistry *mcp.Registry - mcpFactory *mcp.AdapterFactory - mcpExposureFilter mcp.ExposureFilter - mcpExposureAudit []mcp.ExposureDecision + tools map[string]Tool + microCompactPolicies map[string]MicroCompactPolicy + microCompactSummarizers map[string]ContentSummarizer + mcpRegistry *mcp.Registry + mcpFactory *mcp.AdapterFactory + mcpExposureFilter mcp.ExposureFilter + mcpExposureAudit []mcp.ExposureDecision } func NewRegistry() *Registry { return &Registry{ - tools: map[string]Tool{}, - microCompactPolicies: map[string]MicroCompactPolicy{}, + tools: map[string]Tool{}, + microCompactPolicies: map[string]MicroCompactPolicy{}, + microCompactSummarizers: map[string]ContentSummarizer{}, } } @@ -102,6 +104,27 @@ func (r *Registry) MicroCompactPolicy(name string) MicroCompactPolicy { return MicroCompactPolicyCompact } +// RegisterSummarizer 为指定工具注册内容摘要器;传入 nil 移除已有条目。 +func (r *Registry) RegisterSummarizer(toolName string, summarizer ContentSummarizer) { + if r == nil { + return + } + name := strings.ToLower(strings.TrimSpace(toolName)) + if summarizer == nil { + delete(r.microCompactSummarizers, name) + return + } + r.microCompactSummarizers[name] = summarizer +} + +// MicroCompactSummarizer 返回指定工具的内容摘要器;无注册时返回 nil。 +func (r *Registry) MicroCompactSummarizer(name string) ContentSummarizer { + if r == nil || r.microCompactSummarizers == nil { + return nil + } + return r.microCompactSummarizers[strings.ToLower(strings.TrimSpace(name))] +} + func (r *Registry) GetSpecs() []providertypes.ToolSpec { names := make([]string, 0, len(r.tools)) for name := range r.tools { From bad8bba6e879bee6370ce5613a9254d0cfa4e109 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 16 Apr 2026 15:31:01 +0000 Subject: [PATCH 2/8] refactor(context/tools): simplify summarizer wiring and tests Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com> --- internal/context/builder.go | 85 ++++++------ .../tools/micro_compact_summarizer_test.go | 121 ++++++------------ .../micro_compact_summarizers_builtin.go | 30 +++-- 3 files changed, 96 insertions(+), 140 deletions(-) diff --git a/internal/context/builder.go b/internal/context/builder.go index c53a50e3..fed9763c 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -15,6 +15,35 @@ type DefaultBuilder struct { microCompactSummarizers MicroCompactSummarizerSource } +// newDefaultBuilder 统一构建默认上下文构建器,避免多个构造函数重复装配相同依赖。 +func newDefaultBuilder( + policies MicroCompactPolicySource, + summarizers MicroCompactSummarizerSource, + memoSource SectionSource, +) Builder { + return &DefaultBuilder{ + promptSources: newPromptSources(memoSource), + trimPolicy: spanMessageTrimPolicy{}, + microCompactPolicies: policies, + microCompactSummarizers: summarizers, + } +} + +// newPromptSources 组装系统提示词来源列表,并按约定将 memoSource 插入到 systemState 之前。 +func newPromptSources(memoSource SectionSource) []promptSectionSource { + sources := []promptSectionSource{ + corePromptSource{}, + &projectRulesSource{}, + taskStateSource{}, + todosSource{}, + skillPromptSource{}, + } + if memoSource != nil { + sources = append(sources, memoSource) + } + return append(sources, &systemStateSource{gitRunner: runGitCommand}) +} + // NewBuilder returns the default context builder implementation. func NewBuilder() Builder { return NewBuilderWithToolPolicies(nil) @@ -22,37 +51,12 @@ func NewBuilder() Builder { // NewBuilderWithToolPolicies 返回带工具 micro compact 策略源的默认上下文构建器。 func NewBuilderWithToolPolicies(policies MicroCompactPolicySource) Builder { - systemSource := &systemStateSource{gitRunner: runGitCommand} - return &DefaultBuilder{ - promptSources: []promptSectionSource{ - corePromptSource{}, - &projectRulesSource{}, - taskStateSource{}, - todosSource{}, - skillPromptSource{}, - systemSource, - }, - trimPolicy: spanMessageTrimPolicy{}, - microCompactPolicies: policies, - } + return newDefaultBuilder(policies, nil, nil) } // NewBuilderWithToolPoliciesAndSummarizers 返回带工具策略与内容摘要器的上下文构建器。 func NewBuilderWithToolPoliciesAndSummarizers(policies MicroCompactPolicySource, summarizers MicroCompactSummarizerSource) Builder { - systemSource := &systemStateSource{gitRunner: runGitCommand} - return &DefaultBuilder{ - promptSources: []promptSectionSource{ - corePromptSource{}, - &projectRulesSource{}, - taskStateSource{}, - todosSource{}, - skillPromptSource{}, - systemSource, - }, - trimPolicy: spanMessageTrimPolicy{}, - microCompactPolicies: policies, - microCompactSummarizers: summarizers, - } + return newDefaultBuilder(policies, summarizers, nil) } // NewBuilderWithMemo 返回带记忆注入能力的上下文构建器。 @@ -63,24 +67,7 @@ func NewBuilderWithMemo(policies MicroCompactPolicySource, memoSource SectionSou // NewBuilderWithMemoAndSummarizers 返回带记忆注入与内容摘要器的上下文构建器。 func NewBuilderWithMemoAndSummarizers(policies MicroCompactPolicySource, summarizers MicroCompactSummarizerSource, memoSource SectionSource) Builder { - systemSource := &systemStateSource{gitRunner: runGitCommand} - sources := []promptSectionSource{ - corePromptSource{}, - &projectRulesSource{}, - taskStateSource{}, - todosSource{}, - skillPromptSource{}, - } - if memoSource != nil { - sources = append(sources, memoSource) - } - sources = append(sources, systemSource) - return &DefaultBuilder{ - promptSources: sources, - trimPolicy: spanMessageTrimPolicy{}, - microCompactPolicies: policies, - microCompactSummarizers: summarizers, - } + return newDefaultBuilder(policies, summarizers, memoSource) } // Build assembles the provider-facing context for the current round. @@ -121,11 +108,11 @@ func applyReadTimeContextProjection( policies MicroCompactPolicySource, summarizers MicroCompactSummarizerSource, ) []providertypes.Message { - var projected []providertypes.Message if options.DisableMicroCompact || !taskState.Established() { - projected = cloneContextMessages(messages) + return ProjectToolMessagesForModel(cloneContextMessages(messages)) } else { - projected = microCompactMessagesWithPolicies(messages, policies, options.MicroCompactRetainedToolSpans, summarizers) + return ProjectToolMessagesForModel( + microCompactMessagesWithPolicies(messages, policies, options.MicroCompactRetainedToolSpans, summarizers), + ) } - return ProjectToolMessagesForModel(projected) } diff --git a/internal/tools/micro_compact_summarizer_test.go b/internal/tools/micro_compact_summarizer_test.go index b128fe7a..d02938bc 100644 --- a/internal/tools/micro_compact_summarizer_test.go +++ b/internal/tools/micro_compact_summarizer_test.go @@ -15,6 +15,20 @@ func stubMetadata(keyValue ...string) map[string]string { return m } +func assertContains(t *testing.T, got, expected string) { + t.Helper() + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in summary, got %q", expected, got) + } +} + +func assertMaxRuneCount(t *testing.T, got string, max int) { + t.Helper() + if utf8.RuneCountInString(got) > max { + t.Fatalf("summary exceeds %d runes: %d", max, utf8.RuneCountInString(got)) + } +} + func TestBashSummarizer(t *testing.T) { t.Parallel() @@ -22,42 +36,28 @@ func TestBashSummarizer(t *testing.T) { content := "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8" meta := stubMetadata("workdir", "/home/user/project") got := bashSummarizer(content, meta, false) - if !strings.Contains(got, "[exit=0]") { - t.Fatalf("expected exit=0 in summary, got %q", got) - } - if !strings.Contains(got, "workdir=/home/user/project") { - t.Fatalf("expected workdir in summary, got %q", got) - } - if !strings.Contains(got, "line8") { - t.Fatalf("expected last line preserved, got %q", got) - } - if utf8.RuneCountInString(got) > 200 { - t.Fatalf("summary exceeds 200 runes: %d", utf8.RuneCountInString(got)) - } + assertContains(t, got, "[exit=0]") + assertContains(t, got, "workdir=/home/user/project") + assertContains(t, got, "line8") + assertMaxRuneCount(t, got, 200) }) t.Run("error_output", func(t *testing.T) { content := "error: command not found" meta := stubMetadata("workdir", "/tmp") got := bashSummarizer(content, meta, true) - if !strings.Contains(got, "[exit=non-zero]") { - t.Fatalf("expected exit=non-zero in summary, got %q", got) - } + assertContains(t, got, "[exit=non-zero]") }) t.Run("short_output", func(t *testing.T) { content := "ok" got := bashSummarizer(content, nil, false) - if !strings.Contains(got, "ok") { - t.Fatalf("expected content preserved for short output, got %q", got) - } + assertContains(t, got, "ok") }) t.Run("empty_content", func(t *testing.T) { got := bashSummarizer("", nil, false) - if !strings.Contains(got, "[exit=0]") { - t.Fatalf("expected summary even with empty content, got %q", got) - } + assertContains(t, got, "[exit=0]") }) } @@ -68,18 +68,10 @@ func TestReadFileSummarizer(t *testing.T) { content := "package main\n\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n" meta := stubMetadata("path", "/home/user/main.go") got := readFileSummarizer(content, meta, false) - if !strings.Contains(got, "/home/user/main.go") { - t.Fatalf("expected path in summary, got %q", got) - } - if !strings.Contains(got, "lines=") { - t.Fatalf("expected lines count in summary, got %q", got) - } - if !strings.Contains(got, "first=package main") { - t.Fatalf("expected first line in summary, got %q", got) - } - if utf8.RuneCountInString(got) > 200 { - t.Fatalf("summary exceeds 200 runes: %d", utf8.RuneCountInString(got)) - } + assertContains(t, got, "/home/user/main.go") + assertContains(t, got, "lines=") + assertContains(t, got, "first=package main") + assertMaxRuneCount(t, got, 200) }) t.Run("missing_path", func(t *testing.T) { @@ -96,12 +88,8 @@ func TestWriteFileSummarizer(t *testing.T) { t.Run("normal", func(t *testing.T) { meta := stubMetadata("path", "/home/user/test.go", "bytes", "1024") got := writeFileSummarizer("", meta, false) - if !strings.Contains(got, "/home/user/test.go") { - t.Fatalf("expected path in summary, got %q", got) - } - if !strings.Contains(got, "1024 bytes") { - t.Fatalf("expected bytes in summary, got %q", got) - } + assertContains(t, got, "/home/user/test.go") + assertContains(t, got, "1024 bytes") }) t.Run("missing_path", func(t *testing.T) { @@ -118,20 +106,14 @@ func TestEditSummarizer(t *testing.T) { t.Run("with_relative_path", func(t *testing.T) { meta := stubMetadata("relative_path", "src/main.go", "path", "/abs/src/main.go", "search_length", "50", "replacement_length", "60") got := editSummarizer("", meta, false) - if !strings.Contains(got, "src/main.go") { - t.Fatalf("expected relative_path preferred, got %q", got) - } - if !strings.Contains(got, "search=50") { - t.Fatalf("expected search_length, got %q", got) - } + assertContains(t, got, "src/main.go") + assertContains(t, got, "search=50") }) t.Run("fallback_to_abs_path", func(t *testing.T) { meta := stubMetadata("path", "/abs/src/main.go", "search_length", "10", "replacement_length", "20") got := editSummarizer("", meta, false) - if !strings.Contains(got, "/abs/src/main.go") { - t.Fatalf("expected abs path fallback, got %q", got) - } + assertContains(t, got, "/abs/src/main.go") }) t.Run("missing_path", func(t *testing.T) { @@ -149,23 +131,15 @@ func TestGrepSummarizer(t *testing.T) { content := "src/a.go:10:match1\nsrc/b.go:20:match2\nsrc/c.go:30:match3\nsrc/d.go:40:match4" meta := stubMetadata("root", "/home/user", "matched_files", "4", "matched_lines", "4") got := grepSummarizer(content, meta, false) - if !strings.Contains(got, "root=/home/user") { - t.Fatalf("expected root in summary, got %q", got) - } - if !strings.Contains(got, "files=4") { - t.Fatalf("expected files count, got %q", got) - } - if utf8.RuneCountInString(got) > 200 { - t.Fatalf("summary exceeds 200 runes: %d", utf8.RuneCountInString(got)) - } + assertContains(t, got, "root=/home/user") + assertContains(t, got, "files=4") + assertMaxRuneCount(t, got, 200) }) t.Run("empty_content", func(t *testing.T) { meta := stubMetadata("root", "/home", "matched_files", "0", "matched_lines", "0") got := grepSummarizer("", meta, false) - if !strings.Contains(got, "files=0") { - t.Fatalf("expected files count, got %q", got) - } + assertContains(t, got, "files=0") }) } @@ -176,42 +150,29 @@ func TestGlobSummarizer(t *testing.T) { content := "src/a.go\nsrc/b.go\nsrc/c.go\nsrc/d.go" meta := stubMetadata("count", "4") got := globSummarizer(content, meta, false) - if !strings.Contains(got, "4 files") { - t.Fatalf("expected file count, got %q", got) - } - if utf8.RuneCountInString(got) > 200 { - t.Fatalf("summary exceeds 200 runes: %d", utf8.RuneCountInString(got)) - } + assertContains(t, got, "4 files") + assertMaxRuneCount(t, got, 200) }) t.Run("no_matches", func(t *testing.T) { meta := stubMetadata("count", "0") got := globSummarizer("", meta, false) - if !strings.Contains(got, "0 files") { - t.Fatalf("expected 0 files, got %q", got) - } + assertContains(t, got, "0 files") }) } func TestWebfetchSummarizer(t *testing.T) { t.Parallel() - t.Run("with_url", func(t *testing.T) { - meta := stubMetadata("url", "https://example.com/api", "truncated", "true") + t.Run("with_truncated_flag", func(t *testing.T) { + meta := stubMetadata("truncated", "true") got := webfetchSummarizer("", meta, false) - if !strings.Contains(got, "https://example.com/api") { - t.Fatalf("expected url in summary, got %q", got) - } - if !strings.Contains(got, "truncated=true") { - t.Fatalf("expected truncated flag, got %q", got) - } + assertContains(t, got, "truncated=true") }) t.Run("minimal", func(t *testing.T) { got := webfetchSummarizer("", nil, false) - if !strings.Contains(got, "[summary] webfetch") { - t.Fatalf("expected minimal summary, got %q", got) - } + assertContains(t, got, "[summary] webfetch") }) } diff --git a/internal/tools/micro_compact_summarizers_builtin.go b/internal/tools/micro_compact_summarizers_builtin.go index 2dea6aba..ac22e2c3 100644 --- a/internal/tools/micro_compact_summarizers_builtin.go +++ b/internal/tools/micro_compact_summarizers_builtin.go @@ -6,19 +6,30 @@ import ( "unicode/utf8" ) +type builtinSummarizerRegistration struct { + toolName string + summarizer ContentSummarizer +} + +var builtinSummarizers = []builtinSummarizerRegistration{ + {toolName: ToolNameBash, summarizer: bashSummarizer}, + {toolName: ToolNameFilesystemReadFile, summarizer: readFileSummarizer}, + {toolName: ToolNameFilesystemWriteFile, summarizer: writeFileSummarizer}, + {toolName: ToolNameFilesystemEdit, summarizer: editSummarizer}, + {toolName: ToolNameFilesystemGrep, summarizer: grepSummarizer}, + {toolName: ToolNameFilesystemGlob, summarizer: globSummarizer}, + {toolName: ToolNameWebFetch, summarizer: webfetchSummarizer}, +} + // RegisterBuiltinSummarizers 将所有内置工具的内容摘要器注册到 Registry。 // 应在所有工具注册完成后调用一次。 func RegisterBuiltinSummarizers(registry *Registry) { if registry == nil { return } - registry.RegisterSummarizer(ToolNameBash, bashSummarizer) - registry.RegisterSummarizer(ToolNameFilesystemReadFile, readFileSummarizer) - registry.RegisterSummarizer(ToolNameFilesystemWriteFile, writeFileSummarizer) - registry.RegisterSummarizer(ToolNameFilesystemEdit, editSummarizer) - registry.RegisterSummarizer(ToolNameFilesystemGrep, grepSummarizer) - registry.RegisterSummarizer(ToolNameFilesystemGlob, globSummarizer) - registry.RegisterSummarizer(ToolNameWebFetch, webfetchSummarizer) + for _, item := range builtinSummarizers { + registry.RegisterSummarizer(item.toolName, item.summarizer) + } } const summaryMaxRunes = 200 @@ -171,14 +182,11 @@ func globSummarizer(content string, metadata map[string]string, isError bool) st return truncateRunes(strings.Join(parts, " "), summaryMaxRunes) } -// webfetchSummarizer 保留 URL、截断标记等持久化元数据。 +// webfetchSummarizer 保留可稳定持久化的 webfetch 结果标记。 func webfetchSummarizer(content string, metadata map[string]string, isError bool) string { var parts []string parts = append(parts, "[summary] webfetch") - if url := metadata["url"]; url != "" { - parts = append(parts, url) - } if truncated := metadata["truncated"]; truncated == "true" { parts = append(parts, "truncated=true") } From e1f22504f77c4707ae308074cc03a16aa45e9dc1 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 17 Apr 2026 00:23:43 +0000 Subject: [PATCH 3/8] fix(context): harden micro-compact summarization and simplify flow Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com> --- internal/context/microcompact.go | 30 +++++----- .../context/microcompact_summarizer_test.go | 3 + .../tools/micro_compact_summarizer_test.go | 52 ++++++++++++------ .../micro_compact_summarizers_builtin.go | 55 ++++++++++--------- 4 files changed, 84 insertions(+), 56 deletions(-) diff --git a/internal/context/microcompact.go b/internal/context/microcompact.go index b214f413..e1a1424b 100644 --- a/internal/context/microcompact.go +++ b/internal/context/microcompact.go @@ -57,8 +57,8 @@ func microCompactMessagesWithPolicies(messages []providertypes.Message, policies } for messageIndex := span.Start + 1; messageIndex < span.End; messageIndex++ { - if shouldClearToolMessage(cloned[messageIndex], compactableIDs) { - summary := summarizeOrClear(cloned[messageIndex], toolNames, summarizers) + if content, ok := compactableToolMessageContent(cloned[messageIndex], compactableIDs); ok { + summary := summarizeOrClear(cloned[messageIndex], content, toolNames, summarizers) cloned[messageIndex].Parts = []providertypes.ContentPart{providertypes.NewTextPart(summary)} } } @@ -134,32 +134,34 @@ func toolParticipatesInMicroCompact(toolName string, policies MicroCompactPolicy // hasCompactableToolContent 判断工具块中是否存在会影响保留预算的有效工具结果内容。 func hasCompactableToolContent(messages []providertypes.Message, span internalcompact.MessageSpan, compactableIDs map[string]struct{}) bool { for messageIndex := span.Start + 1; messageIndex < span.End; messageIndex++ { - if shouldClearToolMessage(messages[messageIndex], compactableIDs) { + if _, ok := compactableToolMessageContent(messages[messageIndex], compactableIDs); ok { return true } } return false } -// shouldClearToolMessage 判断一条 tool 消息是否满足旧结果清理条件。 -func shouldClearToolMessage(message providertypes.Message, compactableIDs map[string]struct{}) bool { +// compactableToolMessageContent 判断 tool 消息是否可压缩,并返回渲染后的内容文本。 +func compactableToolMessageContent(message providertypes.Message, compactableIDs map[string]struct{}) (string, bool) { if message.Role != providertypes.RoleTool || message.IsError { - return false + return "", false } - if compactableIDs == nil { - return false - } - if _, ok := compactableIDs[strings.TrimSpace(message.ToolCallID)]; !ok { - return false + callID := strings.TrimSpace(message.ToolCallID) + if _, ok := compactableIDs[callID]; !ok { + return "", false } content := strings.TrimSpace(renderDisplayParts(message.Parts)) - return content != "" && content != microCompactClearedMessage + if content == "" || content == microCompactClearedMessage { + return "", false + } + return content, true } // summarizeOrClear 为单条可压缩工具消息生成摘要或回退到默认清除占位。 func summarizeOrClear( message providertypes.Message, + content string, toolNames map[string]string, summarizers MicroCompactSummarizerSource, ) string { @@ -167,7 +169,8 @@ func summarizeOrClear( return microCompactClearedMessage } - toolName, ok := toolNames[strings.TrimSpace(message.ToolCallID)] + callID := strings.TrimSpace(message.ToolCallID) + toolName, ok := toolNames[callID] if !ok { return microCompactClearedMessage } @@ -177,7 +180,6 @@ func summarizeOrClear( return microCompactClearedMessage } - content := renderDisplayParts(message.Parts) summary := summarizer(content, message.ToolMetadata, message.IsError) if summary == "" { return microCompactClearedMessage diff --git a/internal/context/microcompact_summarizer_test.go b/internal/context/microcompact_summarizer_test.go index aea61bb3..72af4240 100644 --- a/internal/context/microcompact_summarizer_test.go +++ b/internal/context/microcompact_summarizer_test.go @@ -229,6 +229,7 @@ func TestSummarizeOrClearWithNilSummarizers(t *testing.T) { got := summarizeOrClear( providertypes.Message{Parts: []providertypes.ContentPart{providertypes.NewTextPart("test")}}, + "test", nil, nil, ) @@ -245,6 +246,7 @@ func TestSummarizeOrClearWithToolNamesLookup(t *testing.T) { toolNames := map[string]string{"call-2": "filesystem_read_file"} got := summarizeOrClear( providertypes.Message{ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("content")}}, + "content", toolNames, stubMicroCompactSummarizerSource{ "filesystem_read_file": func(content string, metadata map[string]string, isError bool) string { @@ -261,6 +263,7 @@ func TestSummarizeOrClearWithToolNamesLookup(t *testing.T) { toolNames := map[string]string{"call-1": "bash"} got := summarizeOrClear( providertypes.Message{ToolCallID: "unknown-id", Parts: []providertypes.ContentPart{providertypes.NewTextPart("content")}}, + "content", toolNames, stubMicroCompactSummarizerSource{}, ) diff --git a/internal/tools/micro_compact_summarizer_test.go b/internal/tools/micro_compact_summarizer_test.go index d02938bc..227156b3 100644 --- a/internal/tools/micro_compact_summarizer_test.go +++ b/internal/tools/micro_compact_summarizer_test.go @@ -29,6 +29,13 @@ func assertMaxRuneCount(t *testing.T, got string, max int) { } } +func assertEmptySummary(t *testing.T, got string) { + t.Helper() + if got != "" { + t.Fatalf("expected empty string, got %q", got) + } +} + func TestBashSummarizer(t *testing.T) { t.Parallel() @@ -38,8 +45,9 @@ func TestBashSummarizer(t *testing.T) { got := bashSummarizer(content, meta, false) assertContains(t, got, "[exit=0]") assertContains(t, got, "workdir=/home/user/project") - assertContains(t, got, "line8") - assertMaxRuneCount(t, got, 200) + assertContains(t, got, "lines=8") + assertContains(t, got, "chars=") + assertMaxRuneCount(t, got, summaryMaxRunes) }) t.Run("error_output", func(t *testing.T) { @@ -52,7 +60,7 @@ func TestBashSummarizer(t *testing.T) { t.Run("short_output", func(t *testing.T) { content := "ok" got := bashSummarizer(content, nil, false) - assertContains(t, got, "ok") + assertContains(t, got, "lines=1") }) t.Run("empty_content", func(t *testing.T) { @@ -69,16 +77,21 @@ func TestReadFileSummarizer(t *testing.T) { meta := stubMetadata("path", "/home/user/main.go") got := readFileSummarizer(content, meta, false) assertContains(t, got, "/home/user/main.go") - assertContains(t, got, "lines=") - assertContains(t, got, "first=package main") - assertMaxRuneCount(t, got, 200) + assertContains(t, got, "lines=5") + assertContains(t, got, "chars=") + assertMaxRuneCount(t, got, summaryMaxRunes) + }) + + t.Run("trailing_newline_not_counted_as_extra_line", func(t *testing.T) { + content := "a\nb\n" + meta := stubMetadata("path", "/tmp/a.txt") + got := readFileSummarizer(content, meta, false) + assertContains(t, got, "lines=2") }) t.Run("missing_path", func(t *testing.T) { got := readFileSummarizer("content", nil, false) - if got != "" { - t.Fatalf("expected empty string for missing path, got %q", got) - } + assertEmptySummary(t, got) }) } @@ -90,13 +103,12 @@ func TestWriteFileSummarizer(t *testing.T) { got := writeFileSummarizer("", meta, false) assertContains(t, got, "/home/user/test.go") assertContains(t, got, "1024 bytes") + assertMaxRuneCount(t, got, summaryMaxRunes) }) t.Run("missing_path", func(t *testing.T) { got := writeFileSummarizer("", stubMetadata("bytes", "100"), false) - if got != "" { - t.Fatalf("expected empty for missing path, got %q", got) - } + assertEmptySummary(t, got) }) } @@ -108,6 +120,7 @@ func TestEditSummarizer(t *testing.T) { got := editSummarizer("", meta, false) assertContains(t, got, "src/main.go") assertContains(t, got, "search=50") + assertMaxRuneCount(t, got, summaryMaxRunes) }) t.Run("fallback_to_abs_path", func(t *testing.T) { @@ -118,9 +131,14 @@ func TestEditSummarizer(t *testing.T) { t.Run("missing_path", func(t *testing.T) { got := editSummarizer("", stubMetadata("search_length", "10"), false) - if got != "" { - t.Fatalf("expected empty for missing path, got %q", got) - } + assertEmptySummary(t, got) + }) + + t.Run("long_path_is_truncated", func(t *testing.T) { + longPath := strings.Repeat("abcdef/", 80) + "main.go" + meta := stubMetadata("path", longPath, "search_length", "10", "replacement_length", "20") + got := editSummarizer("", meta, false) + assertMaxRuneCount(t, got, summaryMaxRunes+3) }) } @@ -133,7 +151,7 @@ func TestGrepSummarizer(t *testing.T) { got := grepSummarizer(content, meta, false) assertContains(t, got, "root=/home/user") assertContains(t, got, "files=4") - assertMaxRuneCount(t, got, 200) + assertMaxRuneCount(t, got, summaryMaxRunes) }) t.Run("empty_content", func(t *testing.T) { @@ -151,7 +169,7 @@ func TestGlobSummarizer(t *testing.T) { meta := stubMetadata("count", "4") got := globSummarizer(content, meta, false) assertContains(t, got, "4 files") - assertMaxRuneCount(t, got, 200) + assertMaxRuneCount(t, got, summaryMaxRunes) }) t.Run("no_matches", func(t *testing.T) { diff --git a/internal/tools/micro_compact_summarizers_builtin.go b/internal/tools/micro_compact_summarizers_builtin.go index ac22e2c3..e9ecab7a 100644 --- a/internal/tools/micro_compact_summarizers_builtin.go +++ b/internal/tools/micro_compact_summarizers_builtin.go @@ -34,7 +34,7 @@ func RegisterBuiltinSummarizers(registry *Registry) { const summaryMaxRunes = 200 -// bashSummarizer 保留退出状态 + 末尾若干行 + 工作目录。 +// bashSummarizer 仅保留结构化执行元信息,避免把原始输出内容重新注入上下文。 func bashSummarizer(content string, metadata map[string]string, isError bool) string { var parts []string @@ -48,42 +48,28 @@ func bashSummarizer(content string, metadata map[string]string, isError bool) st parts = append(parts, "workdir="+workdir) } - const tailLines = 5 - lines := strings.Split(strings.TrimSpace(content), "\n") - if len(lines) > tailLines { - body := "...(truncated)\n" + strings.Join(lines[len(lines)-tailLines:], "\n") - parts = append(parts, body) - } else if len(lines) > 0 && strings.TrimSpace(content) != "" { - parts = append(parts, content) + trimmed := strings.TrimSpace(content) + if trimmed != "" { + parts = appendTextStats(parts, trimmed) } return truncateRunes(strings.Join(parts, " "), summaryMaxRunes) } -// readFileSummarizer 保留文件路径 + 行数 + 首尾行片段。 +// readFileSummarizer 仅保留稳定元信息,避免在摘要中再次暴露文件正文。 func readFileSummarizer(content string, metadata map[string]string, isError bool) string { path := metadata["path"] if path == "" { return "" } - lines := strings.Split(content, "\n") - lineCount := len(lines) + trimmed := strings.TrimRight(content, "\n") + lineCount := stableLineCount(trimmed) var parts []string parts = append(parts, "[summary]", path, "lines="+strconv.Itoa(lineCount)) - - if len(lines) > 0 { - first := truncateRunes(strings.TrimSpace(lines[0]), 60) - if first != "" { - parts = append(parts, "first="+first) - } - } - if len(lines) > 1 { - last := truncateRunes(strings.TrimSpace(lines[len(lines)-1]), 60) - if last != "" && last != strings.TrimSpace(lines[0]) { - parts = append(parts, "last="+last) - } + if trimmed != "" { + parts = append(parts, "chars="+strconv.Itoa(utf8.RuneCountInString(trimmed))) } return truncateRunes(strings.Join(parts, " "), summaryMaxRunes) @@ -96,7 +82,7 @@ func writeFileSummarizer(content string, metadata map[string]string, isError boo return "" } bytes := metadata["bytes"] - return "[summary] wrote " + path + " (" + bytes + " bytes)" + return truncateRunes("[summary] wrote "+path+" ("+bytes+" bytes)", summaryMaxRunes) } // editSummarizer 保留编辑路径与替换范围。 @@ -110,7 +96,10 @@ func editSummarizer(content string, metadata map[string]string, isError bool) st } searchLen := metadata["search_length"] replaceLen := metadata["replacement_length"] - return "[summary] edited " + path + " (search=" + searchLen + " chars, replace=" + replaceLen + " chars)" + return truncateRunes( + "[summary] edited "+path+" (search="+searchLen+" chars, replace="+replaceLen+" chars)", + summaryMaxRunes, + ) } // grepSummarizer 保留搜索根目录、匹配计数与前若干文件名。 @@ -205,3 +194,19 @@ func truncateRunes(text string, maxRunes int) string { runes := []rune(text) return string(runes[:maxRunes]) + "..." } + +// stableLineCount 统计文本行数;空文本返回 0,末尾换行不会产生额外空行计数。 +func stableLineCount(text string) int { + if text == "" { + return 0 + } + return strings.Count(text, "\n") + 1 +} + +// appendTextStats 为摘要补充文本统计字段,保持统一的结构化输出格式。 +func appendTextStats(parts []string, text string) []string { + return append(parts, + "lines="+strconv.Itoa(stableLineCount(text)), + "chars="+strconv.Itoa(utf8.RuneCountInString(text)), + ) +} From 47cf4bce0b6674aaff6f60b73f063153735be072 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 17 Apr 2026 00:49:05 +0000 Subject: [PATCH 4/8] test(context/tools): cover summarizer and manager edge branches Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com> --- internal/context/builder_test.go | 55 ++++++++++++++++ .../context/microcompact_summarizer_test.go | 27 ++++++++ internal/tools/manager_test.go | 57 +++++++++++++++-- .../tools/micro_compact_summarizer_test.go | 63 +++++++++++++++++++ 4 files changed, 197 insertions(+), 5 deletions(-) diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index 8b7b7e05..c24d91ec 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -279,6 +279,61 @@ func TestDefaultBuilderBuildAppliesMicroCompactAfterTrim(t *testing.T) { } } +func TestNewBuilderWithToolPoliciesAndSummarizers(t *testing.T) { + t.Parallel() + + builder := NewBuilderWithToolPoliciesAndSummarizers( + nil, + stubMicroCompactSummarizerSource{ + "filesystem_read_file": func(content string, metadata map[string]string, isError bool) string { + return "[summary] read_file" + }, + }, + ) + + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old read result")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-2", Name: "bash", 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: "webfetch", 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, err := builder.Build(stdcontext.Background(), BuildInput{ + Messages: messages, + TaskState: agentsession.TaskState{Goal: "keep implementing task"}, + Metadata: testMetadata(t.TempDir()), + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + const summarizedMessageIndex = 2 + if renderDisplayParts(got.Messages[summarizedMessageIndex].Parts) != "[summary] read_file" { + t.Fatalf( + "expected summarized older read result, got %q", + renderDisplayParts(got.Messages[summarizedMessageIndex].Parts), + ) + } +} + func TestDefaultBuilderBuildSkipsMicroCompactWithoutEstablishedTaskState(t *testing.T) { t.Parallel() diff --git a/internal/context/microcompact_summarizer_test.go b/internal/context/microcompact_summarizer_test.go index 72af4240..6d03f699 100644 --- a/internal/context/microcompact_summarizer_test.go +++ b/internal/context/microcompact_summarizer_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "neo-code/internal/context/internalcompact" providertypes "neo-code/internal/provider/types" "neo-code/internal/tools" ) @@ -272,3 +273,29 @@ func TestSummarizeOrClearWithToolNamesLookup(t *testing.T) { } }) } + +// TestIsToolCallSpanBoundaries 验证 span 边界异常时返回 false。 +func TestIsToolCallSpanBoundaries(t *testing.T) { + t.Parallel() + + messages := []providertypes.Message{ + {Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{{ID: "c1", Name: "bash"}}}, + } + + if isToolCallSpan(messages, internalcompact.MessageSpan{Start: -1, End: 0}) { + t.Fatal("expected false for negative start") + } + if isToolCallSpan(messages, internalcompact.MessageSpan{Start: 2, End: 3}) { + t.Fatal("expected false for out-of-range start") + } +} + +// TestCompactableToolCallIDsEmptyInput 验证空 tool call 输入时返回 nil。 +func TestCompactableToolCallIDsEmptyInput(t *testing.T) { + t.Parallel() + + ids, names := compactableToolCallIDs(nil, nil) + if ids != nil || names != nil { + t.Fatalf("expected nil maps for empty input, got ids=%v names=%v", ids, names) + } +} diff --git a/internal/tools/manager_test.go b/internal/tools/manager_test.go index 3a63e9f1..e2bd104b 100644 --- a/internal/tools/manager_test.go +++ b/internal/tools/manager_test.go @@ -46,20 +46,20 @@ type stubSandbox struct { lastAction security.Action } -type executorWithoutMicroCompactPolicy struct{} +type executorWithoutOptionalCompactFeatures struct{} -func (executorWithoutMicroCompactPolicy) ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]providertypes.ToolSpec, error) { +func (executorWithoutOptionalCompactFeatures) ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]providertypes.ToolSpec, error) { if err := ctx.Err(); err != nil { return nil, err } return nil, nil } -func (executorWithoutMicroCompactPolicy) Execute(ctx context.Context, call ToolCallInput) (ToolResult, error) { +func (executorWithoutOptionalCompactFeatures) Execute(ctx context.Context, call ToolCallInput) (ToolResult, error) { return ToolResult{}, ctx.Err() } -func (executorWithoutMicroCompactPolicy) Supports(name string) bool { return false } +func (executorWithoutOptionalCompactFeatures) Supports(name string) bool { return false } func (s *stubSandbox) Check(ctx context.Context, action security.Action) (*security.WorkspaceExecutionPlan, error) { s.callCount++ @@ -104,7 +104,7 @@ func TestDefaultManagerMicroCompactPolicy(t *testing.T) { t.Run("executor without policy support defaults to compact", func(t *testing.T) { t.Parallel() - manager, err := NewManager(executorWithoutMicroCompactPolicy{}, nil, nil) + manager, err := NewManager(executorWithoutOptionalCompactFeatures{}, nil, nil) if err != nil { t.Fatalf("new manager: %v", err) } @@ -129,6 +129,53 @@ func TestDefaultManagerMicroCompactPolicy(t *testing.T) { }) } +func TestDefaultManagerMicroCompactSummarizer(t *testing.T) { + t.Parallel() + + t.Run("nil manager returns nil", func(t *testing.T) { + t.Parallel() + + var manager *DefaultManager + if got := manager.MicroCompactSummarizer("custom_tool"); got != nil { + t.Fatalf("expected nil summarizer, got non-nil") + } + }) + + t.Run("executor without summarizer support returns nil", func(t *testing.T) { + t.Parallel() + + manager, err := NewManager(executorWithoutOptionalCompactFeatures{}, nil, nil) + if err != nil { + t.Fatalf("new manager: %v", err) + } + if got := manager.MicroCompactSummarizer("custom_tool"); got != nil { + t.Fatalf("expected nil summarizer, got non-nil") + } + }) + + t.Run("executor summarizer is forwarded", func(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + registry.RegisterSummarizer("custom_tool", func(content string, metadata map[string]string, isError bool) string { + return "summary:" + content + }) + + manager, err := NewManager(registry, nil, nil) + if err != nil { + t.Fatalf("new manager: %v", err) + } + + summarizer := manager.MicroCompactSummarizer("CUSTOM_TOOL") + if summarizer == nil { + t.Fatal("expected non-nil summarizer") + } + if got := summarizer("content", nil, false); got != "summary:content" { + t.Fatalf("unexpected summary output: %q", got) + } + }) +} + func TestDefaultManagerListAvailableSpecsBoundaries(t *testing.T) { t.Parallel() diff --git a/internal/tools/micro_compact_summarizer_test.go b/internal/tools/micro_compact_summarizer_test.go index 227156b3..91e47b11 100644 --- a/internal/tools/micro_compact_summarizer_test.go +++ b/internal/tools/micro_compact_summarizer_test.go @@ -217,6 +217,11 @@ func TestRegisterBuiltinSummarizers(t *testing.T) { } } +func TestRegisterBuiltinSummarizersNilRegistry(t *testing.T) { + t.Parallel() + RegisterBuiltinSummarizers(nil) +} + func TestRegisterSummarizer(t *testing.T) { t.Parallel() @@ -248,6 +253,34 @@ func TestRegisterSummarizer(t *testing.T) { } } +func TestRegisterSummarizerNormalizesName(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + registry.RegisterSummarizer(" Mixed_Tool ", func(content string, metadata map[string]string, isError bool) string { + return "ok" + }) + + if registry.MicroCompactSummarizer("mixed_tool") == nil { + t.Fatal("expected normalized summarizer lookup") + } + if registry.MicroCompactSummarizer(" MIXED_TOOL ") == nil { + t.Fatal("expected case-insensitive summarizer lookup") + } +} + +func TestRegisterSummarizerNilRegistry(t *testing.T) { + t.Parallel() + + var nilRegistry *Registry + nilRegistry.RegisterSummarizer("tool", func(content string, metadata map[string]string, isError bool) string { + return "ok" + }) + if nilRegistry.MicroCompactSummarizer("tool") != nil { + t.Fatal("expected nil summarizer on nil registry") + } +} + func TestTruncateRunes(t *testing.T) { t.Parallel() @@ -278,4 +311,34 @@ func TestTruncateRunes(t *testing.T) { t.Fatalf("expected '你好世...', got %q", got) } }) + + t.Run("zero_limit_keeps_original", func(t *testing.T) { + got := truncateRunes("hello", 0) + if got != "hello" { + t.Fatalf("expected unchanged with zero limit, got %q", got) + } + }) + + t.Run("empty_text", func(t *testing.T) { + got := truncateRunes("", 10) + if got != "" { + t.Fatalf("expected empty string, got %q", got) + } + }) +} + +func TestStableLineCount(t *testing.T) { + t.Parallel() + + t.Run("empty", func(t *testing.T) { + if got := stableLineCount(""); got != 0 { + t.Fatalf("expected 0, got %d", got) + } + }) + + t.Run("non_empty", func(t *testing.T) { + if got := stableLineCount("a\nb"); got != 2 { + t.Fatalf("expected 2, got %d", got) + } + }) } From 54bf046d1481cb0a20abf7fb8a4050aac9704000 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 17 Apr 2026 01:37:22 +0000 Subject: [PATCH 5/8] fix(context): optimize micro compact summarizer paths and sanitize previews Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com> --- internal/context/microcompact.go | 29 ++-- .../tools/micro_compact_summarizer_test.go | 39 ++++++ .../micro_compact_summarizers_builtin.go | 131 +++++++++++++----- 3 files changed, 155 insertions(+), 44 deletions(-) diff --git a/internal/context/microcompact.go b/internal/context/microcompact.go index e1a1424b..30db29c2 100644 --- a/internal/context/microcompact.go +++ b/internal/context/microcompact.go @@ -15,7 +15,7 @@ const ( defaultMicroCompactRetainedToolSpans = 2 ) -// microCompactMessages 对裁剪后的消息做只读投影式微压缩,仅清理旧工具结果内容。 +// microCompactMessages 对裁剪后的消息做只读投影式微压缩,优先摘要旧工具结果,失败时回退清理占位。 func microCompactMessages(messages []providertypes.Message) []providertypes.Message { return microCompactMessagesWithPolicies(messages, nil, 0, nil) } @@ -48,7 +48,8 @@ func microCompactMessagesWithPolicies(messages []providertypes.Message, policies if len(compactableIDs) == 0 { continue } - if !hasCompactableToolContent(cloned, span, compactableIDs) { + compactableContents := compactableToolMessageContents(cloned, span, compactableIDs) + if len(compactableContents) == 0 { continue } if retainedCompactableSpans < retainedToolSpans { @@ -57,10 +58,12 @@ func microCompactMessagesWithPolicies(messages []providertypes.Message, policies } for messageIndex := span.Start + 1; messageIndex < span.End; messageIndex++ { - if content, ok := compactableToolMessageContent(cloned[messageIndex], compactableIDs); ok { - summary := summarizeOrClear(cloned[messageIndex], content, toolNames, summarizers) - cloned[messageIndex].Parts = []providertypes.ContentPart{providertypes.NewTextPart(summary)} + content, ok := compactableContents[messageIndex] + if !ok { + continue } + summary := summarizeOrClear(cloned[messageIndex], content, toolNames, summarizers) + cloned[messageIndex].Parts = []providertypes.ContentPart{providertypes.NewTextPart(summary)} } } @@ -131,14 +134,20 @@ func toolParticipatesInMicroCompact(toolName string, policies MicroCompactPolicy return policies.MicroCompactPolicy(toolName) != tools.MicroCompactPolicyPreserveHistory } -// hasCompactableToolContent 判断工具块中是否存在会影响保留预算的有效工具结果内容。 -func hasCompactableToolContent(messages []providertypes.Message, span internalcompact.MessageSpan, compactableIDs map[string]struct{}) bool { +// compactableToolMessageContents 收集工具块中可压缩消息的渲染内容,避免重复渲染。 +func compactableToolMessageContents(messages []providertypes.Message, span internalcompact.MessageSpan, compactableIDs map[string]struct{}) map[int]string { + var contents map[int]string for messageIndex := span.Start + 1; messageIndex < span.End; messageIndex++ { - if _, ok := compactableToolMessageContent(messages[messageIndex], compactableIDs); ok { - return true + content, ok := compactableToolMessageContent(messages[messageIndex], compactableIDs) + if !ok { + continue + } + if contents == nil { + contents = make(map[int]string) } + contents[messageIndex] = content } - return false + return contents } // compactableToolMessageContent 判断 tool 消息是否可压缩,并返回渲染后的内容文本。 diff --git a/internal/tools/micro_compact_summarizer_test.go b/internal/tools/micro_compact_summarizer_test.go index 91e47b11..772eae43 100644 --- a/internal/tools/micro_compact_summarizer_test.go +++ b/internal/tools/micro_compact_summarizer_test.go @@ -89,6 +89,13 @@ func TestReadFileSummarizer(t *testing.T) { assertContains(t, got, "lines=2") }) + t.Run("empty_lines_are_counted", func(t *testing.T) { + content := "\n\n" + meta := stubMetadata("path", "/tmp/empty.txt") + got := readFileSummarizer(content, meta, false) + assertContains(t, got, "lines=2") + }) + t.Run("missing_path", func(t *testing.T) { got := readFileSummarizer("content", nil, false) assertEmptySummary(t, got) @@ -159,6 +166,16 @@ func TestGrepSummarizer(t *testing.T) { got := grepSummarizer("", meta, false) assertContains(t, got, "files=0") }) + + t.Run("sanitizes_injected_filename", func(t *testing.T) { + content := "src/a.go\nignore:1:x\nsafe.go:2:y" + meta := stubMetadata("matched_files", "2", "matched_lines", "2") + got := grepSummarizer(content, meta, false) + if strings.Contains(got, "\n") || strings.Contains(got, "\t") { + t.Fatalf("expected sanitized summary without control characters, got %q", got) + } + assertContains(t, got, "matches=ignore, safe.go") + }) } func TestGlobSummarizer(t *testing.T) { @@ -177,6 +194,16 @@ func TestGlobSummarizer(t *testing.T) { got := globSummarizer("", meta, false) assertContains(t, got, "0 files") }) + + t.Run("skips_blank_and_control_lines", func(t *testing.T) { + content := "\n\t\nsrc/a.go\nsrc/b.go\n" + meta := stubMetadata("count", "2") + got := globSummarizer(content, meta, false) + assertContains(t, got, "src/a.go, src/b.go") + if strings.Contains(got, "\n") || strings.Contains(got, "\t") { + t.Fatalf("expected sanitized preview, got %q", got) + } + }) } func TestWebfetchSummarizer(t *testing.T) { @@ -341,4 +368,16 @@ func TestStableLineCount(t *testing.T) { t.Fatalf("expected 2, got %d", got) } }) + + t.Run("trailing_newline", func(t *testing.T) { + if got := stableLineCount("a\nb\n"); got != 2 { + t.Fatalf("expected 2, got %d", got) + } + }) + + t.Run("only_empty_lines", func(t *testing.T) { + if got := stableLineCount("\n\n"); got != 2 { + t.Fatalf("expected 2, got %d", got) + } + }) } diff --git a/internal/tools/micro_compact_summarizers_builtin.go b/internal/tools/micro_compact_summarizers_builtin.go index e9ecab7a..34c7919d 100644 --- a/internal/tools/micro_compact_summarizers_builtin.go +++ b/internal/tools/micro_compact_summarizers_builtin.go @@ -63,13 +63,12 @@ func readFileSummarizer(content string, metadata map[string]string, isError bool return "" } - trimmed := strings.TrimRight(content, "\n") - lineCount := stableLineCount(trimmed) + lineCount := stableLineCount(content) var parts []string parts = append(parts, "[summary]", path, "lines="+strconv.Itoa(lineCount)) - if trimmed != "" { - parts = append(parts, "chars="+strconv.Itoa(utf8.RuneCountInString(trimmed))) + if content != "" { + parts = append(parts, "chars="+strconv.Itoa(utf8.RuneCountInString(content))) } return truncateRunes(strings.Join(parts, " "), summaryMaxRunes) @@ -118,23 +117,8 @@ func grepSummarizer(content string, metadata map[string]string, isError bool) st parts = append(parts, "lines="+matchedLines) } - // 从 content 中提取前几个不重复文件名 - contentLines := strings.Split(strings.TrimSpace(content), "\n") - fileSet := make(map[string]struct{}) - var fileNames []string - for _, line := range contentLines { - if len(fileSet) >= 3 { - break - } - idx := strings.Index(line, ":") - if idx > 0 { - f := line[:idx] - if _, ok := fileSet[f]; !ok { - fileSet[f] = struct{}{} - fileNames = append(fileNames, f) - } - } - } + // 从 content 中提取前几个不重复文件名,避免对整段输出做全量切分。 + fileNames := extractUniqueMatchFiles(content, 3) if len(fileNames) > 0 { parts = append(parts, "matches="+strings.Join(fileNames, ", ")) } @@ -149,18 +133,7 @@ func globSummarizer(content string, metadata map[string]string, isError bool) st count = "?" } - contentLines := strings.Split(strings.TrimSpace(content), "\n") - const previewLimit = 3 - var preview []string - for i, line := range contentLines { - if i >= previewLimit { - break - } - trimmed := strings.TrimSpace(line) - if trimmed != "" { - preview = append(preview, trimmed) - } - } + preview := collectPreviewLines(content, 3) var parts []string parts = append(parts, "[summary] glob", count+" files") @@ -200,7 +173,14 @@ func stableLineCount(text string) int { if text == "" { return 0 } - return strings.Count(text, "\n") + 1 + count := strings.Count(text, "\n") + 1 + if strings.HasSuffix(text, "\n") { + count-- + } + if count < 0 { + return 0 + } + return count } // appendTextStats 为摘要补充文本统计字段,保持统一的结构化输出格式。 @@ -210,3 +190,86 @@ func appendTextStats(parts []string, text string) []string { "chars="+strconv.Itoa(utf8.RuneCountInString(text)), ) } + +// extractUniqueMatchFiles 按行扫描 grep 输出,提取前若干个去重后的文件名摘要。 +func extractUniqueMatchFiles(content string, limit int) []string { + if limit <= 0 { + return nil + } + + seen := make(map[string]struct{}, limit) + result := make([]string, 0, limit) + remaining := content + for len(remaining) > 0 && len(result) < limit { + line, rest := nextLine(remaining) + remaining = rest + + colon := strings.Index(line, ":") + if colon <= 0 { + continue + } + + file := sanitizeSummaryToken(line[:colon], 80) + if file == "" { + continue + } + if _, ok := seen[file]; ok { + continue + } + seen[file] = struct{}{} + result = append(result, file) + } + return result +} + +// collectPreviewLines 按行扫描输出并提取前若干个非空预览,避免全量 Split 带来的额外分配。 +func collectPreviewLines(content string, limit int) []string { + if limit <= 0 { + return nil + } + + result := make([]string, 0, limit) + remaining := content + for len(remaining) > 0 && len(result) < limit { + line, rest := nextLine(remaining) + remaining = rest + + clean := sanitizeSummaryToken(line, 100) + if clean == "" { + continue + } + result = append(result, clean) + } + return result +} + +// nextLine 返回 text 的首行及余下文本,兼容存在或不存在换行符的输入。 +func nextLine(text string) (line string, rest string) { + idx := strings.IndexByte(text, '\n') + if idx < 0 { + return text, "" + } + return text[:idx], text[idx+1:] +} + +// sanitizeSummaryToken 清理不可见控制字符并裁剪长度,降低摘要注入风险。 +func sanitizeSummaryToken(text string, maxRunes int) string { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return "" + } + + var b strings.Builder + b.Grow(len(trimmed)) + for _, r := range trimmed { + if r < 32 || r == 127 { + continue + } + b.WriteRune(r) + } + clean := strings.TrimSpace(b.String()) + if clean == "" { + return "" + } + return truncateRunes(clean, maxRunes) +} From 1689255b745ec5fa904737ee66c6bdf91bd01307 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Thu, 16 Apr 2026 22:58:07 +0800 Subject: [PATCH 6/8] =?UTF-8?q?feat(context):=20=E5=BE=AE=E5=8E=8B?= =?UTF-8?q?=E7=BC=A9=E5=BC=95=E5=85=A5=E6=8C=89=E5=B7=A5=E5=85=B7=E5=B7=AE?= =?UTF-8?q?=E5=BC=82=E5=8C=96=E7=9A=84=E9=87=8D=E8=A6=81=E6=80=A7=E6=84=9F?= =?UTF-8?q?=E7=9F=A5=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 旧工具结果在 micro-compact 时统一被替换为"[Old tool result content cleared]", 丢失关键调试上下文(如 bash 错误输出、read_file 文件路径、grep 匹配结果)。 引入 ContentSummarizer 机制,为每个内置工具注册约 200 字符的摘要策略: - bash: 保留退出状态 + 末尾 5 行 + 工作目录 - read_file: 保留文件路径 + 行数 + 首尾行片段 - write_file: 保留文件路径与写入字节数 - edit: 保留编辑路径与替换范围 - grep: 保留搜索根目录 + 匹配计数 + 前几个文件名 - glob: 保留匹配计数与前几个文件名 - webfetch: 保留 URL 与截断标记 无 summarizer 的工具维持原有清除行为,完全向后兼容。 --- internal/app/bootstrap.go | 7 +- internal/context/builder.go | 42 ++- internal/context/microcompact.go | 50 ++- .../context/microcompact_summarizer_test.go | 266 +++++++++++++++ internal/context/microcompact_test.go | 10 +- internal/context/types.go | 5 + .../runtime_remaining_branches_test.go | 4 + internal/runtime/runtime_test.go | 4 + internal/subagent/factory.go | 1 - internal/tools/manager.go | 16 + internal/tools/micro_compact_summarizer.go | 6 + .../tools/micro_compact_summarizer_test.go | 302 ++++++++++++++++++ .../micro_compact_summarizers_builtin.go | 199 ++++++++++++ internal/tools/registry.go | 39 ++- 14 files changed, 918 insertions(+), 33 deletions(-) create mode 100644 internal/context/microcompact_summarizer_test.go create mode 100644 internal/tools/micro_compact_summarizer.go create mode 100644 internal/tools/micro_compact_summarizer_test.go create mode 100644 internal/tools/micro_compact_summarizers_builtin.go diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index afa39fff..3738dc08 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -146,7 +146,10 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er // 这意味着所有会话都归属到启动时指定的项目目录下,运行时不会因配置变更而迁移存储位置。 sessionStore := agentsession.NewStore(loader.BaseDir(), cfg.Workdir) - var contextBuilder agentcontext.Builder = agentcontext.NewBuilderWithToolPolicies(toolRegistry) + // 注册内置工具的内容摘要器,使 micro-compact 在清理旧工具结果时保留关键上下文。 + tools.RegisterBuiltinSummarizers(toolRegistry) + + var contextBuilder agentcontext.Builder = agentcontext.NewBuilderWithToolPoliciesAndSummarizers(toolRegistry, toolRegistry) var memoSvc *memo.Service if cfg.Memo.Enabled { memoStore := memo.NewFileStore(loader.BaseDir(), cfg.Workdir) @@ -155,7 +158,7 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er if invalidator, ok := memoSource.(interface{ InvalidateCache() }); ok { sourceInvl = invalidator.InvalidateCache } - contextBuilder = agentcontext.NewBuilderWithMemo(toolRegistry, memoSource) + contextBuilder = agentcontext.NewBuilderWithMemoAndSummarizers(toolRegistry, toolRegistry, memoSource) memoSvc = memo.NewService(memoStore, nil, cfg.Memo, sourceInvl) toolRegistry.Register(memotool.NewRememberTool(memoSvc)) toolRegistry.Register(memotool.NewRecallTool(memoSvc)) diff --git a/internal/context/builder.go b/internal/context/builder.go index 88369966..c53a50e3 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -9,9 +9,10 @@ import ( // DefaultBuilder preserves the current runtime context-building behavior. type DefaultBuilder struct { - promptSources []promptSectionSource - trimPolicy messageTrimPolicy - microCompactPolicies MicroCompactPolicySource + promptSources []promptSectionSource + trimPolicy messageTrimPolicy + microCompactPolicies MicroCompactPolicySource + microCompactSummarizers MicroCompactSummarizerSource } // NewBuilder returns the default context builder implementation. @@ -36,9 +37,32 @@ func NewBuilderWithToolPolicies(policies MicroCompactPolicySource) Builder { } } +// NewBuilderWithToolPoliciesAndSummarizers 返回带工具策略与内容摘要器的上下文构建器。 +func NewBuilderWithToolPoliciesAndSummarizers(policies MicroCompactPolicySource, summarizers MicroCompactSummarizerSource) Builder { + systemSource := &systemStateSource{gitRunner: runGitCommand} + return &DefaultBuilder{ + promptSources: []promptSectionSource{ + corePromptSource{}, + &projectRulesSource{}, + taskStateSource{}, + todosSource{}, + skillPromptSource{}, + systemSource, + }, + trimPolicy: spanMessageTrimPolicy{}, + microCompactPolicies: policies, + microCompactSummarizers: summarizers, + } +} + // NewBuilderWithMemo 返回带记忆注入能力的上下文构建器。 // memoSource 为 nil 时等价于 NewBuilderWithToolPolicies。 func NewBuilderWithMemo(policies MicroCompactPolicySource, memoSource SectionSource) Builder { + return NewBuilderWithMemoAndSummarizers(policies, nil, memoSource) +} + +// NewBuilderWithMemoAndSummarizers 返回带记忆注入与内容摘要器的上下文构建器。 +func NewBuilderWithMemoAndSummarizers(policies MicroCompactPolicySource, summarizers MicroCompactSummarizerSource, memoSource SectionSource) Builder { systemSource := &systemStateSource{gitRunner: runGitCommand} sources := []promptSectionSource{ corePromptSource{}, @@ -52,9 +76,10 @@ func NewBuilderWithMemo(policies MicroCompactPolicySource, memoSource SectionSou } sources = append(sources, systemSource) return &DefaultBuilder{ - promptSources: sources, - trimPolicy: spanMessageTrimPolicy{}, - microCompactPolicies: policies, + promptSources: sources, + trimPolicy: spanMessageTrimPolicy{}, + microCompactPolicies: policies, + microCompactSummarizers: summarizers, } } @@ -83,7 +108,7 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu return BuildResult{ SystemPrompt: composeSystemPrompt(sections...), - Messages: applyReadTimeContextProjection(trimPolicy.Trim(input.Messages, input.Compact), input.TaskState, input.Compact, b.microCompactPolicies), + Messages: applyReadTimeContextProjection(trimPolicy.Trim(input.Messages, input.Compact), input.TaskState, input.Compact, b.microCompactPolicies, b.microCompactSummarizers), AutoCompactSuggested: shouldAutoCompact, }, nil } @@ -94,12 +119,13 @@ func applyReadTimeContextProjection( taskState agentsession.TaskState, options CompactOptions, policies MicroCompactPolicySource, + summarizers MicroCompactSummarizerSource, ) []providertypes.Message { var projected []providertypes.Message if options.DisableMicroCompact || !taskState.Established() { projected = cloneContextMessages(messages) } else { - projected = microCompactMessagesWithPolicies(messages, policies, options.MicroCompactRetainedToolSpans) + projected = microCompactMessagesWithPolicies(messages, policies, options.MicroCompactRetainedToolSpans, summarizers) } return ProjectToolMessagesForModel(projected) } diff --git a/internal/context/microcompact.go b/internal/context/microcompact.go index 6627d459..7b7a379d 100644 --- a/internal/context/microcompact.go +++ b/internal/context/microcompact.go @@ -17,11 +17,11 @@ const ( // microCompactMessages 对裁剪后的消息做只读投影式微压缩,仅清理旧工具结果内容。 func microCompactMessages(messages []providertypes.Message) []providertypes.Message { - return microCompactMessagesWithPolicies(messages, nil, 0) + return microCompactMessagesWithPolicies(messages, nil, 0, nil) } // microCompactMessagesWithPolicies 按工具策略对裁剪后的消息做只读投影式微压缩。 -func microCompactMessagesWithPolicies(messages []providertypes.Message, policies MicroCompactPolicySource, retainedToolSpans int) []providertypes.Message { +func microCompactMessagesWithPolicies(messages []providertypes.Message, policies MicroCompactPolicySource, retainedToolSpans int, summarizers MicroCompactSummarizerSource) []providertypes.Message { if retainedToolSpans <= 0 { retainedToolSpans = defaultMicroCompactRetainedToolSpans } @@ -44,7 +44,7 @@ func microCompactMessagesWithPolicies(messages []providertypes.Message, policies continue } - compactableIDs := compactableToolCallIDs(cloned[span.Start].ToolCalls, policies) + compactableIDs, toolNames := compactableToolCallIDs(cloned[span.Start].ToolCalls, policies) if len(compactableIDs) == 0 { continue } @@ -58,7 +58,10 @@ func microCompactMessagesWithPolicies(messages []providertypes.Message, policies for messageIndex := span.Start + 1; messageIndex < span.End; messageIndex++ { if shouldClearToolMessage(cloned[messageIndex], compactableIDs) { - cloned[messageIndex].Parts = []providertypes.ContentPart{providertypes.NewTextPart(microCompactClearedMessage)} + summary := summarizeOrClear( + cloned[messageIndex], toolNames, summarizers, + ) + cloned[messageIndex].Parts = []providertypes.ContentPart{providertypes.NewTextPart(summary)} } } } @@ -96,13 +99,14 @@ func isToolCallSpan(messages []providertypes.Message, span internalcompact.Messa return message.Role == providertypes.RoleAssistant && len(message.ToolCalls) > 0 } -// compactableToolCallIDs 返回 assistant tool call 中可参与微压缩的调用 ID 集合。 -func compactableToolCallIDs(calls []providertypes.ToolCall, policies MicroCompactPolicySource) map[string]struct{} { +// compactableToolCallIDs 返回 assistant tool call 中可参与微压缩的调用 ID 集合及对应的工具名映射。 +func compactableToolCallIDs(calls []providertypes.ToolCall, policies MicroCompactPolicySource) (map[string]struct{}, map[string]string) { if len(calls) == 0 { - return nil + return nil, nil } ids := make(map[string]struct{}, len(calls)) + toolNames := make(map[string]string, len(calls)) for _, call := range calls { toolName := strings.TrimSpace(call.Name) if !toolParticipatesInMicroCompact(toolName, policies) { @@ -113,11 +117,12 @@ func compactableToolCallIDs(calls []providertypes.ToolCall, policies MicroCompac continue } ids[callID] = struct{}{} + toolNames[callID] = toolName } if len(ids) == 0 { - return nil + return nil, nil } - return ids + return ids, toolNames } // toolParticipatesInMicroCompact 判断工具是否应参与 micro compact;未知工具默认视为可压缩。 @@ -153,3 +158,30 @@ func shouldClearToolMessage(message providertypes.Message, compactableIDs map[st content := strings.TrimSpace(renderDisplayParts(message.Parts)) return content != "" && content != microCompactClearedMessage } + +// summarizeOrClear 为单条可压缩工具消息生成摘要或回退到默认清除占位。 +func summarizeOrClear( + message providertypes.Message, + toolNames map[string]string, + summarizers MicroCompactSummarizerSource, +) string { + if summarizers == nil { + return microCompactClearedMessage + } + + toolName, ok := toolNames[strings.TrimSpace(message.ToolCallID)] + if !ok { + return microCompactClearedMessage + } + + summarizer := summarizers.MicroCompactSummarizer(toolName) + if summarizer == nil { + return microCompactClearedMessage + } + + summary := summarizer(renderDisplayParts(message.Parts), message.ToolMetadata, message.IsError) + if summary == "" { + return microCompactClearedMessage + } + return summary +} diff --git a/internal/context/microcompact_summarizer_test.go b/internal/context/microcompact_summarizer_test.go new file mode 100644 index 00000000..924fb1fb --- /dev/null +++ b/internal/context/microcompact_summarizer_test.go @@ -0,0 +1,266 @@ +package context + +import ( + "strings" + "testing" + + providertypes "neo-code/internal/provider/types" + "neo-code/internal/tools" +) + +// stubMicroCompactSummarizerSource 实现 MicroCompactSummarizerSource,用于测试。 +type stubMicroCompactSummarizerSource map[string]tools.ContentSummarizer + +func (s stubMicroCompactSummarizerSource) MicroCompactSummarizer(name string) tools.ContentSummarizer { + return s[name] +} + +// TestMicroCompactWithSummarizerProducesSummary 验证注册 summarizer 的工具生成摘要而非清除占位。 +func TestMicroCompactWithSummarizerProducesSummary(t *testing.T) { + t.Parallel() + + bashSummarizer := func(content string, metadata map[string]string, isError bool) string { + return "[summary] bash: " + content + } + + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-1", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old bash result")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-2", Name: "bash", 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: "bash", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest bash result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("current reply")}}, + } + + got := microCompactMessagesWithPolicies( + messages, + stubMicroCompactPolicySource{}, + 0, + stubMicroCompactSummarizerSource{"bash": bashSummarizer}, + ) + + if renderDisplayParts(got[2].Parts) == microCompactClearedMessage { + t.Fatalf("expected summarized content for old bash result, got cleared placeholder") + } + if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] bash:") { + t.Fatalf("expected summary prefix, got %q", renderDisplayParts(got[2].Parts)) + } + if renderDisplayParts(got[4].Parts) != "recent bash result" { + t.Fatalf("expected recent bash result retained, got %q", renderDisplayParts(got[4].Parts)) + } + if renderDisplayParts(got[6].Parts) != "latest bash result" { + t.Fatalf("expected latest bash result retained, got %q", renderDisplayParts(got[6].Parts)) + } + if renderDisplayParts(messages[2].Parts) != "old bash result" { + t.Fatalf("expected original slice unchanged, got %q", renderDisplayParts(messages[2].Parts)) + } +} + +// TestMicroCompactWithoutSummarizerFallsBackToClear 验证未注册 summarizer 的工具仍使用清除占位。 +func TestMicroCompactWithoutSummarizerFallsBackToClear(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: "filesystem_read_file", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old read result")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-2", Name: "bash", 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: "bash", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest bash result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + } + + got := microCompactMessagesWithPolicies( + messages, + stubMicroCompactPolicySource{}, + 0, + stubMicroCompactSummarizerSource{ + "bash": func(content string, metadata map[string]string, isError bool) string { + return "[summary] bash: " + content + }, + }, + ) + + if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { + t.Fatalf("expected cleared placeholder for read_file without summarizer, got %q", renderDisplayParts(got[2].Parts)) + } +} + +// TestMicroCompactMixedSpanWithSummarizer 验证混合工具 span 中部分有摘要、部分清除。 +func TestMicroCompactMixedSpanWithSummarizer(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: "bash", Arguments: "{}"}, + {ID: "call-2", Name: "filesystem_read_file", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("bash output")}}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("read output")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-3", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-4", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-4", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest bash")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("reply")}}, + } + + got := microCompactMessagesWithPolicies( + messages, + stubMicroCompactPolicySource{}, + 0, + stubMicroCompactSummarizerSource{ + "bash": func(content string, metadata map[string]string, isError bool) string { + return "[summary] " + content + }, + }, + ) + + if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary]") { + t.Fatalf("expected bash summary in old span, got %q", renderDisplayParts(got[2].Parts)) + } + if renderDisplayParts(got[3].Parts) != microCompactClearedMessage { + t.Fatalf("expected read_file cleared in old span, got %q", renderDisplayParts(got[3].Parts)) + } +} + +// TestMicroCompactSummarizerReturnsEmptyFallsBackToClear 验证 summarizer 返回空字符串时回退到清除。 +func TestMicroCompactSummarizerReturnsEmptyFallsBackToClear(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: "bash", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old result")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-2", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("middle result")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-3", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + } + + got := microCompactMessagesWithPolicies( + messages, + stubMicroCompactPolicySource{}, + 0, + stubMicroCompactSummarizerSource{ + "bash": func(content string, metadata map[string]string, isError bool) string { + return "" + }, + }, + ) + + if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { + t.Fatalf("expected cleared fallback when summarizer returns empty, got %q", renderDisplayParts(got[2].Parts)) + } +} + +// TestSummarizeOrClearWithNilSummarizers 验证 nil summarizers 回退到清除。 +func TestSummarizeOrClearWithNilSummarizers(t *testing.T) { + t.Parallel() + + got := summarizeOrClear( + providertypes.Message{Parts: []providertypes.ContentPart{providertypes.NewTextPart("test")}}, + nil, + nil, + ) + if got != microCompactClearedMessage { + t.Fatalf("expected cleared message for nil summarizers, got %q", got) + } +} + +// TestSummarizeOrClearWithToolNamesLookup 验证 toolNames map 查找工具名。 +func TestSummarizeOrClearWithToolNamesLookup(t *testing.T) { + t.Parallel() + + t.Run("found", func(t *testing.T) { + toolNames := map[string]string{"call-2": "filesystem_read_file"} + got := summarizeOrClear( + providertypes.Message{ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("content")}}, + toolNames, + stubMicroCompactSummarizerSource{ + "filesystem_read_file": func(content string, metadata map[string]string, isError bool) string { + return "[summary] " + content + }, + }, + ) + if !strings.Contains(got, "[summary]") { + t.Fatalf("expected summary, got %q", got) + } + }) + + t.Run("not_found_in_tool_names", func(t *testing.T) { + toolNames := map[string]string{"call-1": "bash"} + got := summarizeOrClear( + providertypes.Message{ToolCallID: "unknown-id", Parts: []providertypes.ContentPart{providertypes.NewTextPart("content")}}, + toolNames, + stubMicroCompactSummarizerSource{}, + ) + if got != microCompactClearedMessage { + t.Fatalf("expected cleared for unknown tool call id, got %q", got) + } + }) +} diff --git a/internal/context/microcompact_test.go b/internal/context/microcompact_test.go index 6077100a..0264cb8d 100644 --- a/internal/context/microcompact_test.go +++ b/internal/context/microcompact_test.go @@ -79,7 +79,7 @@ func TestMicroCompactMessagesHandlesEmptyAndInvalidSpanInputs(t *testing.T) { }, }, } - got := microCompactMessagesWithPolicies(assistantOnly, stubMicroCompactPolicySource{}, 0) + got := microCompactMessagesWithPolicies(assistantOnly, stubMicroCompactPolicySource{}, 0, nil) if len(got) != 1 || len(got[0].ToolCalls) != 1 { t.Fatalf("expected invalid tool call id path to keep message untouched, got %+v", got) } @@ -173,7 +173,7 @@ func TestMicroCompactMessagesKeepsPreservedToolsErrorsAndOrphans(t *testing.T) { got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ "custom_tool": tools.MicroCompactPolicyPreserveHistory, - }, 0) + }, 0, nil) if renderDisplayParts(got[1].Parts) != "custom result" { t.Fatalf("expected preserved tool result to remain, got %q", renderDisplayParts(got[1].Parts)) } @@ -225,7 +225,7 @@ func TestMicroCompactMessagesClearsOnlyNonPreservedResultsInMixedToolSpan(t *tes got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ "custom_tool": tools.MicroCompactPolicyPreserveHistory, - }, 0) + }, 0, nil) if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { t.Fatalf("expected default compactable tool result to be cleared, got %q", renderDisplayParts(got[2].Parts)) } @@ -266,7 +266,7 @@ func TestMicroCompactMessagesTreatsNewToolsAsCompactableByDefault(t *testing.T) {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, } - got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 0) + got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 0, nil) if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { t.Fatalf("expected new tool result to be compacted by default, got %q", renderDisplayParts(got[2].Parts)) } @@ -341,7 +341,7 @@ func TestMicroCompactMessagesSkipsToolMessagesWhenCompactableIDsMissing(t *testi {Role: providertypes.RoleTool, ToolCallID: "orphan", Parts: []providertypes.ContentPart{providertypes.NewTextPart("orphan result")}}, } - got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 0) + got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 0, nil) if renderDisplayParts(got[0].Parts) != "orphan result" { t.Fatalf("expected orphan tool result to remain, got %q", renderDisplayParts(got[0].Parts)) } diff --git a/internal/context/types.go b/internal/context/types.go index 269fcf21..10877ad0 100644 --- a/internal/context/types.go +++ b/internal/context/types.go @@ -36,6 +36,11 @@ type MicroCompactPolicySource interface { MicroCompactPolicy(name string) tools.MicroCompactPolicy } +// MicroCompactSummarizerSource 定义 context 查找按工具内容摘要器的最小依赖。 +type MicroCompactSummarizerSource interface { + MicroCompactSummarizer(name string) tools.ContentSummarizer +} + // CompactOptions controls read-time compact behavior inside the context builder. type CompactOptions struct { DisableMicroCompact bool diff --git a/internal/runtime/runtime_remaining_branches_test.go b/internal/runtime/runtime_remaining_branches_test.go index 5e1fd701..cab8f1dd 100644 --- a/internal/runtime/runtime_remaining_branches_test.go +++ b/internal/runtime/runtime_remaining_branches_test.go @@ -35,6 +35,10 @@ func (m *callbackToolManager) MicroCompactPolicy(name string) tools.MicroCompact return tools.MicroCompactPolicyCompact } +func (m *callbackToolManager) MicroCompactSummarizer(name string) tools.ContentSummarizer { + return nil +} + func (m *callbackToolManager) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { if m.executeFn != nil { return m.executeFn(ctx, input) diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index f5677dc6..9a2ce8f4 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -380,6 +380,10 @@ func (m *stubToolManager) MicroCompactPolicy(name string) tools.MicroCompactPoli return tools.MicroCompactPolicyCompact } +func (m *stubToolManager) MicroCompactSummarizer(name string) tools.ContentSummarizer { + return nil +} + func (m *stubToolManager) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { m.mu.Lock() m.executeCalls++ diff --git a/internal/subagent/factory.go b/internal/subagent/factory.go index 3b4b19ba..77f7bf05 100644 --- a/internal/subagent/factory.go +++ b/internal/subagent/factory.go @@ -33,4 +33,3 @@ func (f *WorkerFactory) Create(role Role) (WorkerRuntime, error) { } return NewWorker(role, policy, engine) } - diff --git a/internal/tools/manager.go b/internal/tools/manager.go index 51ae622f..b714c254 100644 --- a/internal/tools/manager.go +++ b/internal/tools/manager.go @@ -21,6 +21,7 @@ type SpecListInput struct { type Manager interface { ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]providertypes.ToolSpec, error) MicroCompactPolicy(name string) MicroCompactPolicy + MicroCompactSummarizer(name string) ContentSummarizer // Execute 必须支持并发调用;runtime 可能在同一轮中并行调度多个工具调用。 Execute(ctx context.Context, input ToolCallInput) (ToolResult, error) RememberSessionDecision(sessionID string, action security.Action, scope SessionPermissionScope) error @@ -37,6 +38,10 @@ type microCompactPolicyExecutor interface { MicroCompactPolicy(name string) MicroCompactPolicy } +type microCompactSummarizerExecutor interface { + MicroCompactSummarizer(name string) ContentSummarizer +} + // WorkspaceSandbox enforces workspace-oriented constraints before execution. type WorkspaceSandbox interface { Check(ctx context.Context, action security.Action) (*security.WorkspaceExecutionPlan, error) @@ -181,6 +186,17 @@ func (m *DefaultManager) MicroCompactPolicy(name string) MicroCompactPolicy { return MicroCompactPolicyCompact } +// MicroCompactSummarizer 返回工具的内容摘要器;未注册时返回 nil。 +func (m *DefaultManager) MicroCompactSummarizer(name string) ContentSummarizer { + if m == nil || m.executor == nil { + return nil + } + if source, ok := m.executor.(microCompactSummarizerExecutor); ok { + return source.MicroCompactSummarizer(name) + } + return nil +} + // Execute runs the tool if the permission engine allows it and the sandbox // check passes. func (m *DefaultManager) Execute(ctx context.Context, input ToolCallInput) (ToolResult, error) { diff --git a/internal/tools/micro_compact_summarizer.go b/internal/tools/micro_compact_summarizer.go new file mode 100644 index 00000000..ad5a8274 --- /dev/null +++ b/internal/tools/micro_compact_summarizer.go @@ -0,0 +1,6 @@ +package tools + +// ContentSummarizer 将工具结果内容压缩为短摘要,用于 micro-compact 替换旧工具输出。 +// content 和 metadata 来自持久化后的 Message 字段,isError 标识原始工具是否报错。 +// 返回空字符串表示"无摘要,回退到默认清除行为"。 +type ContentSummarizer func(content string, metadata map[string]string, isError bool) string diff --git a/internal/tools/micro_compact_summarizer_test.go b/internal/tools/micro_compact_summarizer_test.go new file mode 100644 index 00000000..b128fe7a --- /dev/null +++ b/internal/tools/micro_compact_summarizer_test.go @@ -0,0 +1,302 @@ +package tools + +import ( + "strings" + "testing" + "unicode/utf8" +) + +// stubMetadata 快速构建测试用 metadata map。 +func stubMetadata(keyValue ...string) map[string]string { + m := make(map[string]string, len(keyValue)/2) + for i := 0; i+1 < len(keyValue); i += 2 { + m[keyValue[i]] = keyValue[i+1] + } + return m +} + +func TestBashSummarizer(t *testing.T) { + t.Parallel() + + t.Run("normal_output", func(t *testing.T) { + content := "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8" + meta := stubMetadata("workdir", "/home/user/project") + got := bashSummarizer(content, meta, false) + if !strings.Contains(got, "[exit=0]") { + t.Fatalf("expected exit=0 in summary, got %q", got) + } + if !strings.Contains(got, "workdir=/home/user/project") { + t.Fatalf("expected workdir in summary, got %q", got) + } + if !strings.Contains(got, "line8") { + t.Fatalf("expected last line preserved, got %q", got) + } + if utf8.RuneCountInString(got) > 200 { + t.Fatalf("summary exceeds 200 runes: %d", utf8.RuneCountInString(got)) + } + }) + + t.Run("error_output", func(t *testing.T) { + content := "error: command not found" + meta := stubMetadata("workdir", "/tmp") + got := bashSummarizer(content, meta, true) + if !strings.Contains(got, "[exit=non-zero]") { + t.Fatalf("expected exit=non-zero in summary, got %q", got) + } + }) + + t.Run("short_output", func(t *testing.T) { + content := "ok" + got := bashSummarizer(content, nil, false) + if !strings.Contains(got, "ok") { + t.Fatalf("expected content preserved for short output, got %q", got) + } + }) + + t.Run("empty_content", func(t *testing.T) { + got := bashSummarizer("", nil, false) + if !strings.Contains(got, "[exit=0]") { + t.Fatalf("expected summary even with empty content, got %q", got) + } + }) +} + +func TestReadFileSummarizer(t *testing.T) { + t.Parallel() + + t.Run("normal_file", func(t *testing.T) { + content := "package main\n\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n" + meta := stubMetadata("path", "/home/user/main.go") + got := readFileSummarizer(content, meta, false) + if !strings.Contains(got, "/home/user/main.go") { + t.Fatalf("expected path in summary, got %q", got) + } + if !strings.Contains(got, "lines=") { + t.Fatalf("expected lines count in summary, got %q", got) + } + if !strings.Contains(got, "first=package main") { + t.Fatalf("expected first line in summary, got %q", got) + } + if utf8.RuneCountInString(got) > 200 { + t.Fatalf("summary exceeds 200 runes: %d", utf8.RuneCountInString(got)) + } + }) + + t.Run("missing_path", func(t *testing.T) { + got := readFileSummarizer("content", nil, false) + if got != "" { + t.Fatalf("expected empty string for missing path, got %q", got) + } + }) +} + +func TestWriteFileSummarizer(t *testing.T) { + t.Parallel() + + t.Run("normal", func(t *testing.T) { + meta := stubMetadata("path", "/home/user/test.go", "bytes", "1024") + got := writeFileSummarizer("", meta, false) + if !strings.Contains(got, "/home/user/test.go") { + t.Fatalf("expected path in summary, got %q", got) + } + if !strings.Contains(got, "1024 bytes") { + t.Fatalf("expected bytes in summary, got %q", got) + } + }) + + t.Run("missing_path", func(t *testing.T) { + got := writeFileSummarizer("", stubMetadata("bytes", "100"), false) + if got != "" { + t.Fatalf("expected empty for missing path, got %q", got) + } + }) +} + +func TestEditSummarizer(t *testing.T) { + t.Parallel() + + t.Run("with_relative_path", func(t *testing.T) { + meta := stubMetadata("relative_path", "src/main.go", "path", "/abs/src/main.go", "search_length", "50", "replacement_length", "60") + got := editSummarizer("", meta, false) + if !strings.Contains(got, "src/main.go") { + t.Fatalf("expected relative_path preferred, got %q", got) + } + if !strings.Contains(got, "search=50") { + t.Fatalf("expected search_length, got %q", got) + } + }) + + t.Run("fallback_to_abs_path", func(t *testing.T) { + meta := stubMetadata("path", "/abs/src/main.go", "search_length", "10", "replacement_length", "20") + got := editSummarizer("", meta, false) + if !strings.Contains(got, "/abs/src/main.go") { + t.Fatalf("expected abs path fallback, got %q", got) + } + }) + + t.Run("missing_path", func(t *testing.T) { + got := editSummarizer("", stubMetadata("search_length", "10"), false) + if got != "" { + t.Fatalf("expected empty for missing path, got %q", got) + } + }) +} + +func TestGrepSummarizer(t *testing.T) { + t.Parallel() + + t.Run("with_matches", func(t *testing.T) { + content := "src/a.go:10:match1\nsrc/b.go:20:match2\nsrc/c.go:30:match3\nsrc/d.go:40:match4" + meta := stubMetadata("root", "/home/user", "matched_files", "4", "matched_lines", "4") + got := grepSummarizer(content, meta, false) + if !strings.Contains(got, "root=/home/user") { + t.Fatalf("expected root in summary, got %q", got) + } + if !strings.Contains(got, "files=4") { + t.Fatalf("expected files count, got %q", got) + } + if utf8.RuneCountInString(got) > 200 { + t.Fatalf("summary exceeds 200 runes: %d", utf8.RuneCountInString(got)) + } + }) + + t.Run("empty_content", func(t *testing.T) { + meta := stubMetadata("root", "/home", "matched_files", "0", "matched_lines", "0") + got := grepSummarizer("", meta, false) + if !strings.Contains(got, "files=0") { + t.Fatalf("expected files count, got %q", got) + } + }) +} + +func TestGlobSummarizer(t *testing.T) { + t.Parallel() + + t.Run("with_files", func(t *testing.T) { + content := "src/a.go\nsrc/b.go\nsrc/c.go\nsrc/d.go" + meta := stubMetadata("count", "4") + got := globSummarizer(content, meta, false) + if !strings.Contains(got, "4 files") { + t.Fatalf("expected file count, got %q", got) + } + if utf8.RuneCountInString(got) > 200 { + t.Fatalf("summary exceeds 200 runes: %d", utf8.RuneCountInString(got)) + } + }) + + t.Run("no_matches", func(t *testing.T) { + meta := stubMetadata("count", "0") + got := globSummarizer("", meta, false) + if !strings.Contains(got, "0 files") { + t.Fatalf("expected 0 files, got %q", got) + } + }) +} + +func TestWebfetchSummarizer(t *testing.T) { + t.Parallel() + + t.Run("with_url", func(t *testing.T) { + meta := stubMetadata("url", "https://example.com/api", "truncated", "true") + got := webfetchSummarizer("", meta, false) + if !strings.Contains(got, "https://example.com/api") { + t.Fatalf("expected url in summary, got %q", got) + } + if !strings.Contains(got, "truncated=true") { + t.Fatalf("expected truncated flag, got %q", got) + } + }) + + t.Run("minimal", func(t *testing.T) { + got := webfetchSummarizer("", nil, false) + if !strings.Contains(got, "[summary] webfetch") { + t.Fatalf("expected minimal summary, got %q", got) + } + }) +} + +func TestRegisterBuiltinSummarizers(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + RegisterBuiltinSummarizers(registry) + + toolNames := []string{ + ToolNameBash, ToolNameFilesystemReadFile, ToolNameFilesystemWriteFile, + ToolNameFilesystemEdit, ToolNameFilesystemGrep, ToolNameFilesystemGlob, + ToolNameWebFetch, + } + for _, name := range toolNames { + if registry.MicroCompactSummarizer(name) == nil { + t.Errorf("expected summarizer for %q to be registered", name) + } + } + + // 不在注册列表中的工具应返回 nil + if registry.MicroCompactSummarizer("unknown_tool") != nil { + t.Fatal("expected nil for unknown tool") + } +} + +func TestRegisterSummarizer(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + + // 注册 + called := false + registry.RegisterSummarizer("test_tool", func(content string, metadata map[string]string, isError bool) string { + called = true + return "summary" + }) + + s := registry.MicroCompactSummarizer("test_tool") + if s == nil { + t.Fatal("expected summarizer to be registered") + } + result := s("content", nil, false) + if !called { + t.Fatal("expected summarizer to be called") + } + if result != "summary" { + t.Fatalf("expected 'summary', got %q", result) + } + + // 移除 + registry.RegisterSummarizer("test_tool", nil) + if registry.MicroCompactSummarizer("test_tool") != nil { + t.Fatal("expected nil after removal") + } +} + +func TestTruncateRunes(t *testing.T) { + t.Parallel() + + t.Run("short", func(t *testing.T) { + got := truncateRunes("hello", 10) + if got != "hello" { + t.Fatalf("expected unchanged, got %q", got) + } + }) + + t.Run("exact", func(t *testing.T) { + got := truncateRunes("hello", 5) + if got != "hello" { + t.Fatalf("expected unchanged, got %q", got) + } + }) + + t.Run("truncated", func(t *testing.T) { + got := truncateRunes("hello world", 5) + if got != "hello..." { + t.Fatalf("expected 'hello...', got %q", got) + } + }) + + t.Run("chinese", func(t *testing.T) { + got := truncateRunes("你好世界测试", 3) + if got != "你好世..." { + t.Fatalf("expected '你好世...', got %q", got) + } + }) +} diff --git a/internal/tools/micro_compact_summarizers_builtin.go b/internal/tools/micro_compact_summarizers_builtin.go new file mode 100644 index 00000000..2dea6aba --- /dev/null +++ b/internal/tools/micro_compact_summarizers_builtin.go @@ -0,0 +1,199 @@ +package tools + +import ( + "strconv" + "strings" + "unicode/utf8" +) + +// RegisterBuiltinSummarizers 将所有内置工具的内容摘要器注册到 Registry。 +// 应在所有工具注册完成后调用一次。 +func RegisterBuiltinSummarizers(registry *Registry) { + if registry == nil { + return + } + registry.RegisterSummarizer(ToolNameBash, bashSummarizer) + registry.RegisterSummarizer(ToolNameFilesystemReadFile, readFileSummarizer) + registry.RegisterSummarizer(ToolNameFilesystemWriteFile, writeFileSummarizer) + registry.RegisterSummarizer(ToolNameFilesystemEdit, editSummarizer) + registry.RegisterSummarizer(ToolNameFilesystemGrep, grepSummarizer) + registry.RegisterSummarizer(ToolNameFilesystemGlob, globSummarizer) + registry.RegisterSummarizer(ToolNameWebFetch, webfetchSummarizer) +} + +const summaryMaxRunes = 200 + +// bashSummarizer 保留退出状态 + 末尾若干行 + 工作目录。 +func bashSummarizer(content string, metadata map[string]string, isError bool) string { + var parts []string + + if isError { + parts = append(parts, "[exit=non-zero]") + } else { + parts = append(parts, "[exit=0]") + } + + if workdir := metadata["workdir"]; workdir != "" { + parts = append(parts, "workdir="+workdir) + } + + const tailLines = 5 + lines := strings.Split(strings.TrimSpace(content), "\n") + if len(lines) > tailLines { + body := "...(truncated)\n" + strings.Join(lines[len(lines)-tailLines:], "\n") + parts = append(parts, body) + } else if len(lines) > 0 && strings.TrimSpace(content) != "" { + parts = append(parts, content) + } + + return truncateRunes(strings.Join(parts, " "), summaryMaxRunes) +} + +// readFileSummarizer 保留文件路径 + 行数 + 首尾行片段。 +func readFileSummarizer(content string, metadata map[string]string, isError bool) string { + path := metadata["path"] + if path == "" { + return "" + } + + lines := strings.Split(content, "\n") + lineCount := len(lines) + + var parts []string + parts = append(parts, "[summary]", path, "lines="+strconv.Itoa(lineCount)) + + if len(lines) > 0 { + first := truncateRunes(strings.TrimSpace(lines[0]), 60) + if first != "" { + parts = append(parts, "first="+first) + } + } + if len(lines) > 1 { + last := truncateRunes(strings.TrimSpace(lines[len(lines)-1]), 60) + if last != "" && last != strings.TrimSpace(lines[0]) { + parts = append(parts, "last="+last) + } + } + + return truncateRunes(strings.Join(parts, " "), summaryMaxRunes) +} + +// writeFileSummarizer 保留文件路径与写入字节数。 +func writeFileSummarizer(content string, metadata map[string]string, isError bool) string { + path := metadata["path"] + if path == "" { + return "" + } + bytes := metadata["bytes"] + return "[summary] wrote " + path + " (" + bytes + " bytes)" +} + +// editSummarizer 保留编辑路径与替换范围。 +func editSummarizer(content string, metadata map[string]string, isError bool) string { + path := metadata["relative_path"] + if path == "" { + path = metadata["path"] + } + if path == "" { + return "" + } + searchLen := metadata["search_length"] + replaceLen := metadata["replacement_length"] + return "[summary] edited " + path + " (search=" + searchLen + " chars, replace=" + replaceLen + " chars)" +} + +// grepSummarizer 保留搜索根目录、匹配计数与前若干文件名。 +func grepSummarizer(content string, metadata map[string]string, isError bool) string { + var parts []string + parts = append(parts, "[summary] grep") + + if root := metadata["root"]; root != "" { + parts = append(parts, "root="+root) + } + + if matchedFiles := metadata["matched_files"]; matchedFiles != "" { + parts = append(parts, "files="+matchedFiles) + } + if matchedLines := metadata["matched_lines"]; matchedLines != "" { + parts = append(parts, "lines="+matchedLines) + } + + // 从 content 中提取前几个不重复文件名 + contentLines := strings.Split(strings.TrimSpace(content), "\n") + fileSet := make(map[string]struct{}) + var fileNames []string + for _, line := range contentLines { + if len(fileSet) >= 3 { + break + } + idx := strings.Index(line, ":") + if idx > 0 { + f := line[:idx] + if _, ok := fileSet[f]; !ok { + fileSet[f] = struct{}{} + fileNames = append(fileNames, f) + } + } + } + if len(fileNames) > 0 { + parts = append(parts, "matches="+strings.Join(fileNames, ", ")) + } + + return truncateRunes(strings.Join(parts, " "), summaryMaxRunes) +} + +// globSummarizer 保留匹配计数与前若干文件名。 +func globSummarizer(content string, metadata map[string]string, isError bool) string { + count := metadata["count"] + if count == "" { + count = "?" + } + + contentLines := strings.Split(strings.TrimSpace(content), "\n") + const previewLimit = 3 + var preview []string + for i, line := range contentLines { + if i >= previewLimit { + break + } + trimmed := strings.TrimSpace(line) + if trimmed != "" { + preview = append(preview, trimmed) + } + } + + var parts []string + parts = append(parts, "[summary] glob", count+" files") + if len(preview) > 0 { + parts = append(parts, strings.Join(preview, ", ")) + } + + return truncateRunes(strings.Join(parts, " "), summaryMaxRunes) +} + +// webfetchSummarizer 保留 URL、截断标记等持久化元数据。 +func webfetchSummarizer(content string, metadata map[string]string, isError bool) string { + var parts []string + parts = append(parts, "[summary] webfetch") + + if url := metadata["url"]; url != "" { + parts = append(parts, url) + } + if truncated := metadata["truncated"]; truncated == "true" { + parts = append(parts, "truncated=true") + } + + return truncateRunes(strings.Join(parts, " "), summaryMaxRunes) +} + +// truncateRunes 按 rune 数量截断字符串,超出时追加 "..."。 +func truncateRunes(text string, maxRunes int) string { + if maxRunes <= 0 || text == "" { + return text + } + if utf8.RuneCountInString(text) <= maxRunes { + return text + } + runes := []rune(text) + return string(runes[:maxRunes]) + "..." +} diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 998ba939..dd7e4331 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -12,18 +12,20 @@ import ( ) type Registry struct { - tools map[string]Tool - microCompactPolicies map[string]MicroCompactPolicy - mcpRegistry *mcp.Registry - mcpFactory *mcp.AdapterFactory - mcpExposureFilter mcp.ExposureFilter - mcpExposureAudit []mcp.ExposureDecision + tools map[string]Tool + microCompactPolicies map[string]MicroCompactPolicy + microCompactSummarizers map[string]ContentSummarizer + mcpRegistry *mcp.Registry + mcpFactory *mcp.AdapterFactory + mcpExposureFilter mcp.ExposureFilter + mcpExposureAudit []mcp.ExposureDecision } func NewRegistry() *Registry { return &Registry{ - tools: map[string]Tool{}, - microCompactPolicies: map[string]MicroCompactPolicy{}, + tools: map[string]Tool{}, + microCompactPolicies: map[string]MicroCompactPolicy{}, + microCompactSummarizers: map[string]ContentSummarizer{}, } } @@ -102,6 +104,27 @@ func (r *Registry) MicroCompactPolicy(name string) MicroCompactPolicy { return MicroCompactPolicyCompact } +// RegisterSummarizer 为指定工具注册内容摘要器;传入 nil 移除已有条目。 +func (r *Registry) RegisterSummarizer(toolName string, summarizer ContentSummarizer) { + if r == nil { + return + } + name := strings.ToLower(strings.TrimSpace(toolName)) + if summarizer == nil { + delete(r.microCompactSummarizers, name) + return + } + r.microCompactSummarizers[name] = summarizer +} + +// MicroCompactSummarizer 返回指定工具的内容摘要器;无注册时返回 nil。 +func (r *Registry) MicroCompactSummarizer(name string) ContentSummarizer { + if r == nil || r.microCompactSummarizers == nil { + return nil + } + return r.microCompactSummarizers[strings.ToLower(strings.TrimSpace(name))] +} + func (r *Registry) GetSpecs() []providertypes.ToolSpec { names := make([]string, 0, len(r.tools)) for name := range r.tools { From 35956b85bc32291c04705eb93ecfa1a9bf41b53e Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 17 Apr 2026 03:02:31 +0000 Subject: [PATCH 7/8] fix(context/tools): harden summary boundary and sanitize metadata Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com> --- internal/context/microcompact.go | 42 ++++++++++++++++ .../context/microcompact_summarizer_test.go | 48 +++++++++++++++++++ .../tools/micro_compact_summarizer_test.go | 47 ++++++++++++++++++ .../micro_compact_summarizers_builtin.go | 18 ++++--- 4 files changed, 149 insertions(+), 6 deletions(-) diff --git a/internal/context/microcompact.go b/internal/context/microcompact.go index 30db29c2..1e50a7fc 100644 --- a/internal/context/microcompact.go +++ b/internal/context/microcompact.go @@ -13,6 +13,8 @@ const ( microCompactClearedMessage = "[Old tool result content cleared]" // defaultMicroCompactRetainedToolSpans 定义 micro compact 默认保留原始内容的最近可压缩工具块数量。 defaultMicroCompactRetainedToolSpans = 2 + // microCompactSummaryMaxRunes 是摘要回灌到上下文前允许的最大 rune 数量。 + microCompactSummaryMaxRunes = 200 ) // microCompactMessages 对裁剪后的消息做只读投影式微压缩,优先摘要旧工具结果,失败时回退清理占位。 @@ -193,5 +195,45 @@ func summarizeOrClear( if summary == "" { return microCompactClearedMessage } + summary = sanitizeMicroCompactSummary(summary) + if summary == "" { + return microCompactClearedMessage + } return summary } + +// sanitizeMicroCompactSummary 对 summarizer 输出做最终净化与限长,避免把不安全文本直接回灌上下文。 +func sanitizeMicroCompactSummary(summary string) string { + trimmed := strings.TrimSpace(summary) + if trimmed == "" { + return "" + } + + var b strings.Builder + b.Grow(len(trimmed)) + for _, r := range trimmed { + if r < 32 || r == 127 { + continue + } + b.WriteRune(r) + } + + clean := strings.TrimSpace(b.String()) + if clean == "" { + return "" + } + return truncateSummaryRunes(clean, microCompactSummaryMaxRunes) +} + +// truncateSummaryRunes 按 rune 数量截断摘要,超限时追加 "..."。 +func truncateSummaryRunes(summary string, maxRunes int) string { + if maxRunes <= 0 || summary == "" { + return summary + } + + runes := []rune(summary) + if len(runes) <= maxRunes { + return summary + } + return string(runes[:maxRunes]) + "..." +} diff --git a/internal/context/microcompact_summarizer_test.go b/internal/context/microcompact_summarizer_test.go index 6d03f699..d2f26553 100644 --- a/internal/context/microcompact_summarizer_test.go +++ b/internal/context/microcompact_summarizer_test.go @@ -3,6 +3,7 @@ package context import ( "strings" "testing" + "unicode/utf8" "neo-code/internal/context/internalcompact" providertypes "neo-code/internal/provider/types" @@ -274,6 +275,53 @@ func TestSummarizeOrClearWithToolNamesLookup(t *testing.T) { }) } +// TestSummarizeOrClearSanitizesSummary 验证摘要回灌前会执行控制字符净化与长度裁剪。 +func TestSummarizeOrClearSanitizesSummary(t *testing.T) { + t.Parallel() + + raw := strings.Repeat("x", microCompactSummaryMaxRunes+50) + "\n\t\x07" + got := summarizeOrClear( + providertypes.Message{ToolCallID: "call-1"}, + "ignored", + map[string]string{"call-1": "bash"}, + stubMicroCompactSummarizerSource{ + "bash": func(content string, metadata map[string]string, isError bool) string { + return raw + }, + }, + ) + + if strings.ContainsAny(got, "\n\t\a") { + t.Fatalf("expected control characters removed, got %q", got) + } + if utf8.RuneCountInString(got) > microCompactSummaryMaxRunes+3 { + t.Fatalf("expected summary capped, got %d runes", utf8.RuneCountInString(got)) + } + if !strings.HasSuffix(got, "...") { + t.Fatalf("expected truncated summary suffix, got %q", got) + } +} + +// TestSummarizeOrClearSanitizationEmptyFallback 验证净化后为空时会回退清理占位。 +func TestSummarizeOrClearSanitizationEmptyFallback(t *testing.T) { + t.Parallel() + + got := summarizeOrClear( + providertypes.Message{ToolCallID: "call-1"}, + "ignored", + map[string]string{"call-1": "bash"}, + stubMicroCompactSummarizerSource{ + "bash": func(content string, metadata map[string]string, isError bool) string { + return "\n\t\x07 " + }, + }, + ) + + if got != microCompactClearedMessage { + t.Fatalf("expected cleared fallback when sanitized summary is empty, got %q", got) + } +} + // TestIsToolCallSpanBoundaries 验证 span 边界异常时返回 false。 func TestIsToolCallSpanBoundaries(t *testing.T) { t.Parallel() diff --git a/internal/tools/micro_compact_summarizer_test.go b/internal/tools/micro_compact_summarizer_test.go index dde604bd..90ee6d7b 100644 --- a/internal/tools/micro_compact_summarizer_test.go +++ b/internal/tools/micro_compact_summarizer_test.go @@ -67,6 +67,15 @@ func TestBashSummarizer(t *testing.T) { got := bashSummarizer("", nil, false) assertContains(t, got, "[exit=0]") }) + + t.Run("sanitizes_workdir_metadata", func(t *testing.T) { + meta := stubMetadata("workdir", " \n\t/tmp/proj\x07 ") + got := bashSummarizer("ok", meta, false) + if strings.ContainsAny(got, "\n\t\a") { + t.Fatalf("expected sanitized workdir without control characters, got %q", got) + } + assertContains(t, got, "workdir=/tmp/proj") + }) } func TestReadFileSummarizer(t *testing.T) { @@ -100,6 +109,16 @@ func TestReadFileSummarizer(t *testing.T) { got := readFileSummarizer("content", nil, false) assertEmptySummary(t, got) }) + + t.Run("sanitizes_path_metadata", func(t *testing.T) { + content := "line1\nline2" + meta := stubMetadata("path", " \n\t/tmp/a.go\x07 ") + got := readFileSummarizer(content, meta, false) + if strings.ContainsAny(got, "\n\t\a") { + t.Fatalf("expected sanitized path without control characters, got %q", got) + } + assertContains(t, got, "/tmp/a.go") + }) } func TestWriteFileSummarizer(t *testing.T) { @@ -117,6 +136,15 @@ func TestWriteFileSummarizer(t *testing.T) { got := writeFileSummarizer("", stubMetadata("bytes", "100"), false) assertEmptySummary(t, got) }) + + t.Run("sanitizes_path_metadata", func(t *testing.T) { + meta := stubMetadata("path", " \n\t/tmp/out.go\x07 ", "bytes", "4") + got := writeFileSummarizer("", meta, false) + if strings.ContainsAny(got, "\n\t\a") { + t.Fatalf("expected sanitized path without control characters, got %q", got) + } + assertContains(t, got, "/tmp/out.go") + }) } func TestEditSummarizer(t *testing.T) { @@ -141,6 +169,15 @@ func TestEditSummarizer(t *testing.T) { assertEmptySummary(t, got) }) + t.Run("sanitizes_path_metadata", func(t *testing.T) { + meta := stubMetadata("relative_path", " \n\tsrc/main.go\x07 ", "search_length", "10", "replacement_length", "12") + got := editSummarizer("", meta, false) + if strings.ContainsAny(got, "\n\t\a") { + t.Fatalf("expected sanitized path without control characters, got %q", got) + } + assertContains(t, got, "src/main.go") + }) + t.Run("long_path_is_truncated", func(t *testing.T) { longPath := strings.Repeat("abcdef/", 80) + "main.go" meta := stubMetadata("path", longPath, "search_length", "10", "replacement_length", "20") @@ -167,6 +204,16 @@ func TestGrepSummarizer(t *testing.T) { assertContains(t, got, "files=0") }) + t.Run("sanitizes_root_metadata", func(t *testing.T) { + content := "a.go:1:x" + meta := stubMetadata("root", " \n\t/tmp/root\x07 ", "matched_files", "1", "matched_lines", "1") + got := grepSummarizer(content, meta, false) + if strings.ContainsAny(got, "\n\t\a") { + t.Fatalf("expected sanitized root without control characters, got %q", got) + } + assertContains(t, got, "root=/tmp/root") + }) + t.Run("sanitizes_injected_filename", func(t *testing.T) { content := "src/a.go\nignore:1:x\nsafe.go:2:y" meta := stubMetadata("matched_files", "2", "matched_lines", "2") diff --git a/internal/tools/micro_compact_summarizers_builtin.go b/internal/tools/micro_compact_summarizers_builtin.go index 34c7919d..dfdf271b 100644 --- a/internal/tools/micro_compact_summarizers_builtin.go +++ b/internal/tools/micro_compact_summarizers_builtin.go @@ -33,6 +33,7 @@ func RegisterBuiltinSummarizers(registry *Registry) { } const summaryMaxRunes = 200 +const metadataTokenMaxRunes = 120 // bashSummarizer 仅保留结构化执行元信息,避免把原始输出内容重新注入上下文。 func bashSummarizer(content string, metadata map[string]string, isError bool) string { @@ -44,7 +45,7 @@ func bashSummarizer(content string, metadata map[string]string, isError bool) st parts = append(parts, "[exit=0]") } - if workdir := metadata["workdir"]; workdir != "" { + if workdir := metadataToken(metadata["workdir"]); workdir != "" { parts = append(parts, "workdir="+workdir) } @@ -58,7 +59,7 @@ func bashSummarizer(content string, metadata map[string]string, isError bool) st // readFileSummarizer 仅保留稳定元信息,避免在摘要中再次暴露文件正文。 func readFileSummarizer(content string, metadata map[string]string, isError bool) string { - path := metadata["path"] + path := metadataToken(metadata["path"]) if path == "" { return "" } @@ -76,7 +77,7 @@ func readFileSummarizer(content string, metadata map[string]string, isError bool // writeFileSummarizer 保留文件路径与写入字节数。 func writeFileSummarizer(content string, metadata map[string]string, isError bool) string { - path := metadata["path"] + path := metadataToken(metadata["path"]) if path == "" { return "" } @@ -86,9 +87,9 @@ func writeFileSummarizer(content string, metadata map[string]string, isError boo // editSummarizer 保留编辑路径与替换范围。 func editSummarizer(content string, metadata map[string]string, isError bool) string { - path := metadata["relative_path"] + path := metadataToken(metadata["relative_path"]) if path == "" { - path = metadata["path"] + path = metadataToken(metadata["path"]) } if path == "" { return "" @@ -106,7 +107,7 @@ func grepSummarizer(content string, metadata map[string]string, isError bool) st var parts []string parts = append(parts, "[summary] grep") - if root := metadata["root"]; root != "" { + if root := metadataToken(metadata["root"]); root != "" { parts = append(parts, "root="+root) } @@ -273,3 +274,8 @@ func sanitizeSummaryToken(text string, maxRunes int) string { } return truncateRunes(clean, maxRunes) } + +// metadataToken 统一清理 metadata 中可回灌到摘要的文本字段。 +func metadataToken(text string) string { + return sanitizeSummaryToken(text, metadataTokenMaxRunes) +} From 4f1873a75ceaa379412239a1e13e621d3cbd684a Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 17 Apr 2026 03:53:21 +0000 Subject: [PATCH 8/8] fix(context/tools): resolve unresolved simplify review items Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com> --- internal/context/microcompact.go | 21 ++++++++++--- .../context/microcompact_summarizer_test.go | 30 ++++++++++++++++++ .../tools/micro_compact_summarizer_test.go | 31 +++++++++++++++++++ .../micro_compact_summarizers_builtin.go | 2 +- internal/tools/registry.go | 11 ++++++- 5 files changed, 89 insertions(+), 6 deletions(-) diff --git a/internal/context/microcompact.go b/internal/context/microcompact.go index 1e50a7fc..37993eb2 100644 --- a/internal/context/microcompact.go +++ b/internal/context/microcompact.go @@ -50,12 +50,15 @@ func microCompactMessagesWithPolicies(messages []providertypes.Message, policies if len(compactableIDs) == 0 { continue } - compactableContents := compactableToolMessageContents(cloned, span, compactableIDs) - if len(compactableContents) == 0 { + if retainedCompactableSpans < retainedToolSpans { + if hasCompactableToolMessage(cloned, span, compactableIDs) { + retainedCompactableSpans++ + } continue } - if retainedCompactableSpans < retainedToolSpans { - retainedCompactableSpans++ + + compactableContents := compactableToolMessageContents(cloned, span, compactableIDs) + if len(compactableContents) == 0 { continue } @@ -152,6 +155,16 @@ func compactableToolMessageContents(messages []providertypes.Message, span inter return contents } +// hasCompactableToolMessage 判断工具块中是否存在至少一条可压缩的工具消息。 +func hasCompactableToolMessage(messages []providertypes.Message, span internalcompact.MessageSpan, compactableIDs map[string]struct{}) bool { + for messageIndex := span.Start + 1; messageIndex < span.End; messageIndex++ { + if _, ok := compactableToolMessageContent(messages[messageIndex], compactableIDs); ok { + return true + } + } + return false +} + // compactableToolMessageContent 判断 tool 消息是否可压缩,并返回渲染后的内容文本。 func compactableToolMessageContent(message providertypes.Message, compactableIDs map[string]struct{}) (string, bool) { if message.Role != providertypes.RoleTool || message.IsError { diff --git a/internal/context/microcompact_summarizer_test.go b/internal/context/microcompact_summarizer_test.go index d2f26553..12ffc2e9 100644 --- a/internal/context/microcompact_summarizer_test.go +++ b/internal/context/microcompact_summarizer_test.go @@ -347,3 +347,33 @@ func TestCompactableToolCallIDsEmptyInput(t *testing.T) { t.Fatalf("expected nil maps for empty input, got ids=%v names=%v", ids, names) } } + +// TestHasCompactableToolMessage 验证工具块可压缩消息探测逻辑。 +func TestHasCompactableToolMessage(t *testing.T) { + t.Parallel() + + span := internalcompact.MessageSpan{Start: 0, End: 3} + ids := map[string]struct{}{"call-1": {}} + + t.Run("true_when_matching_tool_message_exists", func(t *testing.T) { + messages := []providertypes.Message{ + {Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{{ID: "call-1", Name: "bash"}}}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("output")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("u")}}, + } + if !hasCompactableToolMessage(messages, span, ids) { + t.Fatal("expected compactable tool message to be found") + } + }) + + t.Run("false_when_tool_messages_are_not_compactable", func(t *testing.T) { + messages := []providertypes.Message{ + {Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{{ID: "call-1", Name: "bash"}}}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", IsError: true, Parts: []providertypes.ContentPart{providertypes.NewTextPart("error")}}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("other")}}, + } + if hasCompactableToolMessage(messages, span, ids) { + t.Fatal("expected no compactable tool message") + } + }) +} diff --git a/internal/tools/micro_compact_summarizer_test.go b/internal/tools/micro_compact_summarizer_test.go index 90ee6d7b..d01901e6 100644 --- a/internal/tools/micro_compact_summarizer_test.go +++ b/internal/tools/micro_compact_summarizer_test.go @@ -2,6 +2,7 @@ package tools import ( "strings" + "sync" "testing" "unicode/utf8" ) @@ -354,6 +355,36 @@ func TestRegisterSummarizerNilRegistry(t *testing.T) { t.Fatal("expected nil summarizer on nil registry") } } + +func TestRegisterSummarizerConcurrentAccess(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + var wg sync.WaitGroup + + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 200; j++ { + if j%3 == 0 { + registry.RegisterSummarizer("concurrent_tool", nil) + continue + } + registry.RegisterSummarizer("concurrent_tool", func(content string, metadata map[string]string, isError bool) string { + return "worker" + }) + s := registry.MicroCompactSummarizer("concurrent_tool") + if s != nil { + _ = s("content", nil, false) + } + } + }() + } + + wg.Wait() +} + func TestTruncateRunes(t *testing.T) { t.Parallel() diff --git a/internal/tools/micro_compact_summarizers_builtin.go b/internal/tools/micro_compact_summarizers_builtin.go index dfdf271b..d18481ae 100644 --- a/internal/tools/micro_compact_summarizers_builtin.go +++ b/internal/tools/micro_compact_summarizers_builtin.go @@ -22,7 +22,7 @@ var builtinSummarizers = []builtinSummarizerRegistration{ } // RegisterBuiltinSummarizers 将所有内置工具的内容摘要器注册到 Registry。 -// 应在所有工具注册完成后调用一次。 +// 建议在启动装配阶段调用;可重复调用并覆盖同名摘要器。 func RegisterBuiltinSummarizers(registry *Registry) { if registry == nil { return diff --git a/internal/tools/registry.go b/internal/tools/registry.go index dd7e4331..45a1e3fd 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -5,6 +5,7 @@ import ( "errors" "sort" "strings" + "sync" providertypes "neo-code/internal/provider/types" "neo-code/internal/security" @@ -15,6 +16,7 @@ type Registry struct { tools map[string]Tool microCompactPolicies map[string]MicroCompactPolicy microCompactSummarizers map[string]ContentSummarizer + microCompactSummaryMu sync.RWMutex mcpRegistry *mcp.Registry mcpFactory *mcp.AdapterFactory mcpExposureFilter mcp.ExposureFilter @@ -110,6 +112,8 @@ func (r *Registry) RegisterSummarizer(toolName string, summarizer ContentSummari return } name := strings.ToLower(strings.TrimSpace(toolName)) + r.microCompactSummaryMu.Lock() + defer r.microCompactSummaryMu.Unlock() if summarizer == nil { delete(r.microCompactSummarizers, name) return @@ -119,7 +123,12 @@ func (r *Registry) RegisterSummarizer(toolName string, summarizer ContentSummari // MicroCompactSummarizer 返回指定工具的内容摘要器;无注册时返回 nil。 func (r *Registry) MicroCompactSummarizer(name string) ContentSummarizer { - if r == nil || r.microCompactSummarizers == nil { + if r == nil { + return nil + } + r.microCompactSummaryMu.RLock() + defer r.microCompactSummaryMu.RUnlock() + if r.microCompactSummarizers == nil { return nil } return r.microCompactSummarizers[strings.ToLower(strings.TrimSpace(name))]