From bc53f8c147303200b4fc5d6c27f13b2912bab15f Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 5 Apr 2026 14:24:42 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat(context):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=97=A7=E5=B7=A5=E5=85=B7=E7=BB=93=E6=9E=9C=E7=9A=84=E8=AF=BB?= =?UTF-8?q?=E6=97=B6=20Micro=20Compact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/context/builder.go | 2 +- internal/context/builder_test.go | 54 +++++++ internal/context/microcompact.go | 128 ++++++++++++++++ internal/context/microcompact_test.go | 203 ++++++++++++++++++++++++++ 4 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 internal/context/microcompact.go create mode 100644 internal/context/microcompact_test.go diff --git a/internal/context/builder.go b/internal/context/builder.go index ff1487ea..f1ca2c79 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -43,6 +43,6 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu return BuildResult{ SystemPrompt: composeSystemPrompt(sections...), - Messages: trimPolicy.Trim(input.Messages), + Messages: microCompactMessages(trimPolicy.Trim(input.Messages)), }, nil } diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index 42c002ba..9f5098a0 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -150,6 +150,60 @@ func TestDefaultBuilderBuildReturnsPromptSourceError(t *testing.T) { } } +func TestDefaultBuilderBuildAppliesMicroCompactAfterTrim(t *testing.T) { + t.Parallel() + + builder := &DefaultBuilder{ + promptSources: []promptSectionSource{ + stubPromptSectionSource{sections: []promptSection{{title: "Stub", content: "body"}}}, + }, + } + + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "older user"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "old read result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-2", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-3", Name: "webfetch", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + {Role: provider.RoleUser, Content: "latest explicit instruction"}, + {Role: provider.RoleAssistant, Content: "current reply"}, + } + + got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages}) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if len(got.Messages) != len(messages) { + t.Fatalf("expected builder output to keep message count, got %d want %d", len(got.Messages), len(messages)) + } + if got.Messages[2].Content != microCompactClearedMessage { + t.Fatalf("expected builder output to clear older tool result, got %q", got.Messages[2].Content) + } + if got.Messages[4].Content != "recent bash result" { + t.Fatalf("expected recent tool result to stay visible, got %q", got.Messages[4].Content) + } + if got.Messages[6].Content != "latest webfetch result" { + t.Fatalf("expected latest tool result to stay visible, got %q", got.Messages[6].Content) + } +} + func TestTrimMessagesPreservesToolPairs(t *testing.T) { t.Parallel() diff --git a/internal/context/microcompact.go b/internal/context/microcompact.go new file mode 100644 index 00000000..1eec70c0 --- /dev/null +++ b/internal/context/microcompact.go @@ -0,0 +1,128 @@ +package context + +import ( + "strings" + + "neo-code/internal/context/internalcompact" + "neo-code/internal/provider" +) + +const ( + // microCompactClearedMessage 是旧工具结果被读时微压缩后的占位符文本。 + microCompactClearedMessage = "[Old tool result content cleared]" + // microCompactRetainedToolSpans 定义默认保留原始内容的最近可压缩工具块数量。 + microCompactRetainedToolSpans = 2 +) + +var microCompactableTools = map[string]struct{}{ + "bash": {}, + "webfetch": {}, + "filesystem_read_file": {}, + "filesystem_grep": {}, + "filesystem_glob": {}, + "filesystem_edit": {}, + "filesystem_write_file": {}, +} + +// microCompactMessages 对裁剪后的消息做只读投影式微压缩,仅清理旧工具结果内容。 +func microCompactMessages(messages []provider.Message) []provider.Message { + cloned := cloneContextMessages(messages) + if len(cloned) == 0 { + return cloned + } + + spans := internalcompact.BuildMessageSpans(cloned) + protectedStart, hasProtectedTail := internalcompact.ProtectedTailStart(spans) + retainedCompactableSpans := 0 + + for spanIndex := len(spans) - 1; spanIndex >= 0; spanIndex-- { + span := spans[spanIndex] + if hasProtectedTail && span.Start >= protectedStart { + continue + } + if !isToolCallSpan(cloned, span) { + continue + } + + compactableIDs := compactableToolCallIDs(cloned[span.Start].ToolCalls) + if len(compactableIDs) == 0 { + continue + } + if retainedCompactableSpans < microCompactRetainedToolSpans { + retainedCompactableSpans++ + continue + } + + for messageIndex := span.Start + 1; messageIndex < span.End; messageIndex++ { + if shouldClearToolMessage(cloned[messageIndex], compactableIDs) { + cloned[messageIndex].Content = microCompactClearedMessage + } + } + } + + return cloned +} + +// cloneContextMessages 深拷贝消息切片,避免读时投影污染 runtime 持有的原始会话消息。 +func cloneContextMessages(messages []provider.Message) []provider.Message { + if len(messages) == 0 { + return nil + } + + cloned := make([]provider.Message, 0, len(messages)) + for _, message := range messages { + next := message + next.ToolCalls = append([]provider.ToolCall(nil), message.ToolCalls...) + cloned = append(cloned, next) + } + return cloned +} + +// isToolCallSpan 判断当前 span 是否是由 assistant tool call 起始的原子工具块。 +func isToolCallSpan(messages []provider.Message, span internalcompact.MessageSpan) bool { + if span.Start < 0 || span.Start >= len(messages) { + return false + } + message := messages[span.Start] + return message.Role == provider.RoleAssistant && len(message.ToolCalls) > 0 +} + +// compactableToolCallIDs 返回 assistant tool call 中可参与微压缩的调用 ID 集合。 +func compactableToolCallIDs(calls []provider.ToolCall) map[string]struct{} { + if len(calls) == 0 { + return nil + } + + ids := make(map[string]struct{}, len(calls)) + for _, call := range calls { + toolName := strings.TrimSpace(call.Name) + if _, ok := microCompactableTools[toolName]; !ok { + continue + } + callID := strings.TrimSpace(call.ID) + if callID == "" { + continue + } + ids[callID] = struct{}{} + } + if len(ids) == 0 { + return nil + } + return ids +} + +// shouldClearToolMessage 判断一条 tool 消息是否满足旧结果清理条件。 +func shouldClearToolMessage(message provider.Message, compactableIDs map[string]struct{}) bool { + if message.Role != provider.RoleTool || message.IsError { + return false + } + if compactableIDs == nil { + return false + } + if _, ok := compactableIDs[strings.TrimSpace(message.ToolCallID)]; !ok { + return false + } + + content := strings.TrimSpace(message.Content) + return content != "" && content != microCompactClearedMessage +} diff --git a/internal/context/microcompact_test.go b/internal/context/microcompact_test.go new file mode 100644 index 00000000..b0cce67e --- /dev/null +++ b/internal/context/microcompact_test.go @@ -0,0 +1,203 @@ +package context + +import ( + "testing" + + "neo-code/internal/provider" +) + +func TestMicroCompactMessagesClearsOlderCompactableToolResults(t *testing.T) { + t.Parallel() + + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "older user"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "old read result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-2", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-3", Name: "webfetch", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + {Role: provider.RoleUser, Content: "latest explicit instruction"}, + {Role: provider.RoleAssistant, Content: "current working reply"}, + } + + got := microCompactMessages(messages) + if len(got) != len(messages) { + t.Fatalf("expected message count to stay unchanged, got %d want %d", len(got), len(messages)) + } + if got[2].Content != microCompactClearedMessage { + t.Fatalf("expected oldest compactable tool result to be cleared, got %q", got[2].Content) + } + if got[4].Content != "recent bash result" { + t.Fatalf("expected recent compactable tool result to be retained, got %q", got[4].Content) + } + if got[6].Content != "latest webfetch result" { + t.Fatalf("expected latest compactable tool result to be retained, got %q", got[6].Content) + } + if messages[2].Content != "old read result" { + t.Fatalf("expected original slice to remain unchanged, got %q", messages[2].Content) + } +} + +func TestMicroCompactMessagesKeepsProtectedTailUntouched(t *testing.T) { + t.Parallel() + + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "older user"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-0", Name: "filesystem_grep", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-0", Content: "old grep result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "recent read result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-2", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + {Role: provider.RoleUser, Content: "latest explicit instruction"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-3", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-3", Content: "tail bash result"}, + } + + got := microCompactMessages(messages) + if got[2].Content != microCompactClearedMessage { + t.Fatalf("expected old tool result before protected tail to be cleared, got %q", got[2].Content) + } + if got[4].Content != "recent read result" { + t.Fatalf("expected recent tool result before protected tail to remain, got %q", got[4].Content) + } + if got[6].Content != "recent bash result" { + t.Fatalf("expected second recent tool result before protected tail to remain, got %q", got[6].Content) + } + if got[9].Content != "tail bash result" { + t.Fatalf("expected protected tail tool result to remain, got %q", got[9].Content) + } +} + +func TestMicroCompactMessagesSkipsNonCompactableErrorsAndOrphans(t *testing.T) { + t.Parallel() + + messages := []provider.Message{ + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "custom_tool", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "custom result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-2", Name: "filesystem_edit", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-2", Content: "edit failed", IsError: true}, + {Role: provider.RoleTool, ToolCallID: "orphan", Content: "orphan result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-3", Name: "filesystem_write_file", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-3", Content: microCompactClearedMessage}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-4", Name: "filesystem_grep", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-4", Content: ""}, + } + + got := microCompactMessages(messages) + if got[1].Content != "custom result" { + t.Fatalf("expected non-compactable tool result to remain, got %q", got[1].Content) + } + if got[3].Content != "edit failed" { + t.Fatalf("expected error tool result to remain, got %q", got[3].Content) + } + if got[4].Content != "orphan result" { + t.Fatalf("expected orphan tool result to remain, got %q", got[4].Content) + } + if got[6].Content != microCompactClearedMessage { + t.Fatalf("expected already cleared content to remain unchanged, got %q", got[6].Content) + } + if got[8].Content != "" { + t.Fatalf("expected empty tool result to remain empty, got %q", got[8].Content) + } +} + +func TestMicroCompactMessagesClearsOnlyCompactableResultsInMixedToolSpan(t *testing.T) { + t.Parallel() + + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "older user"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, + {ID: "call-2", Name: "custom_tool", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "read result"}, + {Role: provider.RoleTool, ToolCallID: "call-2", Content: "custom result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-3", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-3", Content: "recent bash result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-4", Name: "webfetch", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-4", Content: "latest webfetch result"}, + {Role: provider.RoleUser, Content: "latest explicit instruction"}, + {Role: provider.RoleAssistant, Content: "current reply"}, + } + + got := microCompactMessages(messages) + if got[2].Content != microCompactClearedMessage { + t.Fatalf("expected compactable tool result to be cleared, got %q", got[2].Content) + } + if got[3].Content != "custom result" { + t.Fatalf("expected non-compactable tool result in mixed span to remain, got %q", got[3].Content) + } + if len(got[1].ToolCalls) != 2 { + t.Fatalf("expected assistant tool call metadata to remain intact, got %+v", got[1].ToolCalls) + } +} From d12e43dd4e61251d45c3f6ba17e0d04187aa56a3 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 5 Apr 2026 14:51:37 +0800 Subject: [PATCH 2/9] fix(context): avoid empty spans consuming micro compact budget --- internal/context/microcompact.go | 13 ++++++ internal/context/microcompact_test.go | 62 +++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/internal/context/microcompact.go b/internal/context/microcompact.go index 1eec70c0..10f5baba 100644 --- a/internal/context/microcompact.go +++ b/internal/context/microcompact.go @@ -48,6 +48,9 @@ func microCompactMessages(messages []provider.Message) []provider.Message { if len(compactableIDs) == 0 { continue } + if !hasCompactableToolContent(cloned, span, compactableIDs) { + continue + } if retainedCompactableSpans < microCompactRetainedToolSpans { retainedCompactableSpans++ continue @@ -111,6 +114,16 @@ func compactableToolCallIDs(calls []provider.ToolCall) map[string]struct{} { return ids } +// hasCompactableToolContent 判断工具块中是否存在会影响保留预算的有效工具结果内容。 +func hasCompactableToolContent(messages []provider.Message, span internalcompact.MessageSpan, compactableIDs map[string]struct{}) bool { + for messageIndex := span.Start + 1; messageIndex < span.End; messageIndex++ { + if shouldClearToolMessage(messages[messageIndex], compactableIDs) { + return true + } + } + return false +} + // shouldClearToolMessage 判断一条 tool 消息是否满足旧结果清理条件。 func shouldClearToolMessage(message provider.Message, compactableIDs map[string]struct{}) bool { if message.Role != provider.RoleTool || message.IsError { diff --git a/internal/context/microcompact_test.go b/internal/context/microcompact_test.go index b0cce67e..77b7b078 100644 --- a/internal/context/microcompact_test.go +++ b/internal/context/microcompact_test.go @@ -201,3 +201,65 @@ func TestMicroCompactMessagesClearsOnlyCompactableResultsInMixedToolSpan(t *test t.Fatalf("expected assistant tool call metadata to remain intact, got %+v", got[1].ToolCalls) } } + +func TestMicroCompactMessagesSkipsEmptyRecentSpansWhenCountingRetainedBudget(t *testing.T) { + t.Parallel() + + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "older user"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "older read result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-2", Name: "filesystem_grep", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-2", Content: "middle grep result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-3", Name: "filesystem_edit", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-3", Content: "near edit result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-4", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-4", Content: "", IsError: true}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-5", Name: "webfetch", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-5", Content: ""}, + {Role: provider.RoleUser, Content: "latest explicit instruction"}, + {Role: provider.RoleAssistant, Content: "current reply"}, + } + + got := microCompactMessages(messages) + if got[2].Content != microCompactClearedMessage { + t.Fatalf("expected oldest valid tool result to be cleared, got %q", got[2].Content) + } + if got[4].Content != "middle grep result" { + t.Fatalf("expected middle valid tool result to remain, got %q", got[4].Content) + } + if got[6].Content != "near edit result" { + t.Fatalf("expected nearer valid tool result to remain, got %q", got[6].Content) + } + if got[8].Content != "" { + t.Fatalf("expected error/empty tool result to remain unchanged, got %q", got[8].Content) + } + if got[10].Content != "" { + t.Fatalf("expected empty recent tool result to remain unchanged, got %q", got[10].Content) + } +} From 04b273c54fce0c8b6ce736176467616fd314c77e Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 5 Apr 2026 15:12:20 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix(context):=20=E5=A2=9E=E5=8A=A0=20micro?= =?UTF-8?q?=20compact=20=E5=9B=9E=E9=80=80=E5=BC=80=E5=85=B3=E5=B9=B6?= =?UTF-8?q?=E6=94=B6=E6=95=9B=E5=B7=A5=E5=85=B7=E5=90=8D=E5=B8=B8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guides/configuration.md | 2 ++ internal/config/config_test.go | 10 ++++++ internal/config/loader.go | 3 ++ internal/config/model.go | 1 + internal/context/builder.go | 16 +++++++-- internal/context/builder_test.go | 54 ++++++++++++++++++++++++++++ internal/context/microcompact.go | 15 ++++---- internal/context/types.go | 6 ++++ internal/runtime/runtime.go | 3 ++ internal/runtime/runtime_test.go | 44 +++++++++++++++++++++++ internal/tools/bash/tool.go | 2 +- internal/tools/filesystem/helpers.go | 12 ++++--- internal/tools/names.go | 12 +++++++ internal/tools/webfetch/tool.go | 2 +- 14 files changed, 166 insertions(+), 16 deletions(-) create mode 100644 internal/tools/names.go diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index fed73849..155e29d9 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -315,6 +315,7 @@ context: manual_strategy: keep_recent manual_keep_recent_messages: 10 max_summary_chars: 1200 + micro_compact_disabled: false ``` ### 字段说明 @@ -324,5 +325,6 @@ context: | `context.compact.manual_strategy` | string | `keep_recent` | 手动 `/compact` 策略,可选 `keep_recent` / `full_replace` | | `context.compact.manual_keep_recent_messages` | int | `10` | `keep_recent` 模式下保留最近 N 条消息;会按 tool call 与 tool result 的原子块整体保留 | | `context.compact.max_summary_chars` | int | `1200` | compact summary 最大字符数 | +| `context.compact.micro_compact_disabled` | bool | `false` | 是否关闭默认启用的读时 micro compact;设为 `true` 可快速回退到仅 trim、不做旧工具结果清理 | 更多行为说明见 [context-compact.md](../context-compact.md)。 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4516c5e0..a2504ac2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -876,10 +876,14 @@ func TestCompactConfigDefaultsAndRoundTrip(t *testing.T) { if compactCfg.MaxSummaryChars != DefaultCompactMaxSummaryChars { t.Fatalf("expected max_summary_chars=%d, got %d", DefaultCompactMaxSummaryChars, compactCfg.MaxSummaryChars) } + if compactCfg.MicroCompactDisabled { + t.Fatalf("expected micro compact to be enabled by default") + } cfg.Context.Compact.ManualStrategy = CompactManualStrategyFullReplace cfg.Context.Compact.ManualKeepRecentMessages = 2 cfg.Context.Compact.MaxSummaryChars = 900 + cfg.Context.Compact.MicroCompactDisabled = true if err := loader.Save(context.Background(), cfg); err != nil { t.Fatalf("Save() error = %v", err) } @@ -894,6 +898,9 @@ func TestCompactConfigDefaultsAndRoundTrip(t *testing.T) { if strings.Contains(text, "manual_keep_recent_spans:") { t.Fatalf("expected persisted config to drop legacy manual_keep_recent_spans key, got:\n%s", text) } + if !strings.Contains(text, "micro_compact_disabled: true") { + t.Fatalf("expected persisted config to include micro_compact_disabled, got:\n%s", text) + } reloaded, err := loader.Load(context.Background()) if err != nil { @@ -908,6 +915,9 @@ func TestCompactConfigDefaultsAndRoundTrip(t *testing.T) { if reloaded.Context.Compact.MaxSummaryChars != 900 { t.Fatalf("expected max_summary_chars=900, got %d", reloaded.Context.Compact.MaxSummaryChars) } + if !reloaded.Context.Compact.MicroCompactDisabled { + t.Fatalf("expected micro_compact_disabled to persist") + } } func TestCompactConfigValidateFailures(t *testing.T) { diff --git a/internal/config/loader.go b/internal/config/loader.go index 6c8ef449..ea3a6c3e 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -41,6 +41,7 @@ type persistedCompactConfig struct { ManualStrategy string `yaml:"manual_strategy,omitempty"` ManualKeepRecentMessages int `yaml:"manual_keep_recent_messages,omitempty"` MaxSummaryChars int `yaml:"max_summary_chars,omitempty"` + MicroCompactDisabled bool `yaml:"micro_compact_disabled,omitempty"` } func NewLoader(baseDir string, defaults *Config) *Loader { @@ -217,6 +218,7 @@ func newPersistedContextConfig(cfg ContextConfig) persistedContextConfig { ManualStrategy: cfg.Compact.ManualStrategy, ManualKeepRecentMessages: cfg.Compact.ManualKeepRecentMessages, MaxSummaryChars: cfg.Compact.MaxSummaryChars, + MicroCompactDisabled: cfg.Compact.MicroCompactDisabled, }, } } @@ -228,6 +230,7 @@ func fromPersistedContextConfig(file persistedContextConfig, defaults ContextCon ManualStrategy: strings.TrimSpace(file.Compact.ManualStrategy), ManualKeepRecentMessages: file.Compact.ManualKeepRecentMessages, MaxSummaryChars: file.Compact.MaxSummaryChars, + MicroCompactDisabled: file.Compact.MicroCompactDisabled, }, } out.Compact.ApplyDefaults(defaults.Compact) diff --git a/internal/config/model.go b/internal/config/model.go index e8d1d3c9..75992d1c 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -70,6 +70,7 @@ type CompactConfig struct { ManualStrategy string `yaml:"manual_strategy,omitempty"` ManualKeepRecentMessages int `yaml:"manual_keep_recent_messages,omitempty"` MaxSummaryChars int `yaml:"max_summary_chars,omitempty"` + MicroCompactDisabled bool `yaml:"micro_compact_disabled,omitempty"` } type WebFetchConfig struct { diff --git a/internal/context/builder.go b/internal/context/builder.go index f1ca2c79..eeac9406 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -1,6 +1,10 @@ package context -import "context" +import ( + "context" + + "neo-code/internal/provider" +) // DefaultBuilder preserves the current runtime context-building behavior. type DefaultBuilder struct { @@ -43,6 +47,14 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu return BuildResult{ SystemPrompt: composeSystemPrompt(sections...), - Messages: microCompactMessages(trimPolicy.Trim(input.Messages)), + Messages: applyReadTimeContextProjection(trimPolicy.Trim(input.Messages), input.Compact), }, nil } + +// applyReadTimeContextProjection 负责在 provider 请求前按开关应用只读上下文投影,避免改写原始会话消息。 +func applyReadTimeContextProjection(messages []provider.Message, options CompactOptions) []provider.Message { + if options.DisableMicroCompact { + return cloneContextMessages(messages) + } + return microCompactMessages(messages) +} diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index 9f5098a0..8b72951d 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "strings" "testing" @@ -204,6 +205,59 @@ func TestDefaultBuilderBuildAppliesMicroCompactAfterTrim(t *testing.T) { } } +func TestDefaultBuilderBuildSkipsMicroCompactWhenDisabled(t *testing.T) { + t.Parallel() + + builder := &DefaultBuilder{ + promptSources: []promptSectionSource{ + stubPromptSectionSource{sections: []promptSection{{title: "Stub", content: "body"}}}, + }, + } + + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "older user"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "old read result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-2", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-3", Name: "webfetch", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + {Role: provider.RoleUser, Content: "latest explicit instruction"}, + {Role: provider.RoleAssistant, Content: "current reply"}, + } + + got, err := builder.Build(stdcontext.Background(), BuildInput{ + Messages: messages, + Compact: CompactOptions{ + DisableMicroCompact: true, + }, + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if !reflect.DeepEqual(got.Messages, messages) { + t.Fatalf("expected messages to remain unchanged when micro compact is disabled, got %+v", got.Messages) + } + if &got.Messages[2] == &messages[2] { + t.Fatalf("expected disabled path to still clone message slice") + } +} + func TestTrimMessagesPreservesToolPairs(t *testing.T) { t.Parallel() diff --git a/internal/context/microcompact.go b/internal/context/microcompact.go index 10f5baba..10a36cde 100644 --- a/internal/context/microcompact.go +++ b/internal/context/microcompact.go @@ -5,6 +5,7 @@ import ( "neo-code/internal/context/internalcompact" "neo-code/internal/provider" + "neo-code/internal/tools" ) const ( @@ -15,13 +16,13 @@ const ( ) var microCompactableTools = map[string]struct{}{ - "bash": {}, - "webfetch": {}, - "filesystem_read_file": {}, - "filesystem_grep": {}, - "filesystem_glob": {}, - "filesystem_edit": {}, - "filesystem_write_file": {}, + tools.ToolNameBash: {}, + tools.ToolNameWebFetch: {}, + tools.ToolNameFilesystemReadFile: {}, + tools.ToolNameFilesystemGrep: {}, + tools.ToolNameFilesystemGlob: {}, + tools.ToolNameFilesystemEdit: {}, + tools.ToolNameFilesystemWriteFile: {}, } // microCompactMessages 对裁剪后的消息做只读投影式微压缩,仅清理旧工具结果内容。 diff --git a/internal/context/types.go b/internal/context/types.go index 6eb516dd..2e406861 100644 --- a/internal/context/types.go +++ b/internal/context/types.go @@ -15,6 +15,7 @@ type Builder interface { type BuildInput struct { Messages []provider.Message Metadata Metadata + Compact CompactOptions } // BuildResult is the provider-facing context produced for a single round. @@ -22,3 +23,8 @@ type BuildResult struct { SystemPrompt string Messages []provider.Message } + +// CompactOptions controls read-time compact behavior inside the context builder. +type CompactOptions struct { + DisableMicroCompact bool +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index ae3b380a..2e3b7c29 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -157,6 +157,9 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { Provider: cfg.SelectedProvider, Model: cfg.CurrentModel, }, + Compact: agentcontext.CompactOptions{ + DisableMicroCompact: cfg.Context.Compact.MicroCompactDisabled, + }, }) if err != nil { return s.handleRunError(ctx, input.RunID, session.ID, err) diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 7f3b5da5..3a4bec17 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -477,6 +477,9 @@ func TestServiceRunDelegatesToContextBuilder(t *testing.T) { if builder.lastInput.Metadata.Model == "" { t.Fatalf("expected model to be forwarded to builder metadata") } + if builder.lastInput.Compact.DisableMicroCompact { + t.Fatalf("expected micro compact to stay enabled by default") + } if len(builder.lastInput.Messages) != 1 || builder.lastInput.Messages[0].Content != "hello" { t.Fatalf("expected persisted session messages to be forwarded, got %+v", builder.lastInput.Messages) } @@ -491,6 +494,47 @@ func TestServiceRunDelegatesToContextBuilder(t *testing.T) { } } +func TestServiceRunCanDisableMicroCompactViaConfig(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Context.Compact.MicroCompactDisabled = true + return nil + }); err != nil { + t.Fatalf("update config: %v", err) + } + + store := newMemoryStore() + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + builder := &stubContextBuilder{ + buildFn: func(ctx context.Context, input agentcontext.BuildInput) (agentcontext.BuildResult, error) { + return agentcontext.BuildResult{ + SystemPrompt: "delegated prompt", + Messages: append([]provider.Message(nil), input.Messages...), + }, nil + }, + } + + scripted := &scriptedProvider{ + responses: []provider.ChatResponse{{ + Message: provider.Message{Role: provider.RoleAssistant, Content: "done"}, + FinishReason: "stop", + }}, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, builder) + if err := service.Run(context.Background(), UserInput{RunID: "run-disable-micro-compact", Content: "hello"}); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if !builder.lastInput.Compact.DisableMicroCompact { + t.Fatalf("expected config to disable micro compact in build input") + } +} + func TestServiceRunPersistsSessionProviderAndModel(t *testing.T) { t.Parallel() diff --git a/internal/tools/bash/tool.go b/internal/tools/bash/tool.go index 9facbb88..5c00c2c7 100644 --- a/internal/tools/bash/tool.go +++ b/internal/tools/bash/tool.go @@ -35,7 +35,7 @@ func New(root string, shell string, timeout time.Duration) *Tool { } func (t *Tool) Name() string { - return "bash" + return tools.ToolNameBash } func (t *Tool) Description() string { diff --git a/internal/tools/filesystem/helpers.go b/internal/tools/filesystem/helpers.go index d681ed6e..929d74e1 100644 --- a/internal/tools/filesystem/helpers.go +++ b/internal/tools/filesystem/helpers.go @@ -4,14 +4,16 @@ import ( "os" "path/filepath" "strings" + + "neo-code/internal/tools" ) const ( - readFileToolName = "filesystem_read_file" - writeFileToolName = "filesystem_write_file" - grepToolName = "filesystem_grep" - globToolName = "filesystem_glob" - editToolName = "filesystem_edit" + readFileToolName = tools.ToolNameFilesystemReadFile + writeFileToolName = tools.ToolNameFilesystemWriteFile + grepToolName = tools.ToolNameFilesystemGrep + globToolName = tools.ToolNameFilesystemGlob + editToolName = tools.ToolNameFilesystemEdit ) func effectiveRoot(defaultRoot string, workdir string) string { diff --git a/internal/tools/names.go b/internal/tools/names.go new file mode 100644 index 00000000..833430f7 --- /dev/null +++ b/internal/tools/names.go @@ -0,0 +1,12 @@ +package tools + +// Tool name constants are shared across tool implementations, context policies, and tests. +const ( + ToolNameBash = "bash" + ToolNameWebFetch = "webfetch" + ToolNameFilesystemReadFile = "filesystem_read_file" + ToolNameFilesystemWriteFile = "filesystem_write_file" + ToolNameFilesystemGrep = "filesystem_grep" + ToolNameFilesystemGlob = "filesystem_glob" + ToolNameFilesystemEdit = "filesystem_edit" +) diff --git a/internal/tools/webfetch/tool.go b/internal/tools/webfetch/tool.go index efa5474f..5a77c2b2 100644 --- a/internal/tools/webfetch/tool.go +++ b/internal/tools/webfetch/tool.go @@ -16,7 +16,7 @@ import ( ) const ( - toolName = "webfetch" + toolName = tools.ToolNameWebFetch htmlContentType = "text/html" xhtmlContentType = "application/xhtml+xml" reasonInvalidArguments = "invalid arguments" From b3b0f88b02fb2a4883e5bc504734225a9e593e7d Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 5 Apr 2026 14:24:42 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat(context):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=97=A7=E5=B7=A5=E5=85=B7=E7=BB=93=E6=9E=9C=E7=9A=84=E8=AF=BB?= =?UTF-8?q?=E6=97=B6=20Micro=20Compact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/context/builder.go | 2 +- internal/context/builder_test.go | 54 +++++++ internal/context/microcompact.go | 128 ++++++++++++++++ internal/context/microcompact_test.go | 203 ++++++++++++++++++++++++++ 4 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 internal/context/microcompact.go create mode 100644 internal/context/microcompact_test.go diff --git a/internal/context/builder.go b/internal/context/builder.go index ff1487ea..f1ca2c79 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -43,6 +43,6 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu return BuildResult{ SystemPrompt: composeSystemPrompt(sections...), - Messages: trimPolicy.Trim(input.Messages), + Messages: microCompactMessages(trimPolicy.Trim(input.Messages)), }, nil } diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index 42c002ba..9f5098a0 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -150,6 +150,60 @@ func TestDefaultBuilderBuildReturnsPromptSourceError(t *testing.T) { } } +func TestDefaultBuilderBuildAppliesMicroCompactAfterTrim(t *testing.T) { + t.Parallel() + + builder := &DefaultBuilder{ + promptSources: []promptSectionSource{ + stubPromptSectionSource{sections: []promptSection{{title: "Stub", content: "body"}}}, + }, + } + + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "older user"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "old read result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-2", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-3", Name: "webfetch", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + {Role: provider.RoleUser, Content: "latest explicit instruction"}, + {Role: provider.RoleAssistant, Content: "current reply"}, + } + + got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages}) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if len(got.Messages) != len(messages) { + t.Fatalf("expected builder output to keep message count, got %d want %d", len(got.Messages), len(messages)) + } + if got.Messages[2].Content != microCompactClearedMessage { + t.Fatalf("expected builder output to clear older tool result, got %q", got.Messages[2].Content) + } + if got.Messages[4].Content != "recent bash result" { + t.Fatalf("expected recent tool result to stay visible, got %q", got.Messages[4].Content) + } + if got.Messages[6].Content != "latest webfetch result" { + t.Fatalf("expected latest tool result to stay visible, got %q", got.Messages[6].Content) + } +} + func TestTrimMessagesPreservesToolPairs(t *testing.T) { t.Parallel() diff --git a/internal/context/microcompact.go b/internal/context/microcompact.go new file mode 100644 index 00000000..1eec70c0 --- /dev/null +++ b/internal/context/microcompact.go @@ -0,0 +1,128 @@ +package context + +import ( + "strings" + + "neo-code/internal/context/internalcompact" + "neo-code/internal/provider" +) + +const ( + // microCompactClearedMessage 是旧工具结果被读时微压缩后的占位符文本。 + microCompactClearedMessage = "[Old tool result content cleared]" + // microCompactRetainedToolSpans 定义默认保留原始内容的最近可压缩工具块数量。 + microCompactRetainedToolSpans = 2 +) + +var microCompactableTools = map[string]struct{}{ + "bash": {}, + "webfetch": {}, + "filesystem_read_file": {}, + "filesystem_grep": {}, + "filesystem_glob": {}, + "filesystem_edit": {}, + "filesystem_write_file": {}, +} + +// microCompactMessages 对裁剪后的消息做只读投影式微压缩,仅清理旧工具结果内容。 +func microCompactMessages(messages []provider.Message) []provider.Message { + cloned := cloneContextMessages(messages) + if len(cloned) == 0 { + return cloned + } + + spans := internalcompact.BuildMessageSpans(cloned) + protectedStart, hasProtectedTail := internalcompact.ProtectedTailStart(spans) + retainedCompactableSpans := 0 + + for spanIndex := len(spans) - 1; spanIndex >= 0; spanIndex-- { + span := spans[spanIndex] + if hasProtectedTail && span.Start >= protectedStart { + continue + } + if !isToolCallSpan(cloned, span) { + continue + } + + compactableIDs := compactableToolCallIDs(cloned[span.Start].ToolCalls) + if len(compactableIDs) == 0 { + continue + } + if retainedCompactableSpans < microCompactRetainedToolSpans { + retainedCompactableSpans++ + continue + } + + for messageIndex := span.Start + 1; messageIndex < span.End; messageIndex++ { + if shouldClearToolMessage(cloned[messageIndex], compactableIDs) { + cloned[messageIndex].Content = microCompactClearedMessage + } + } + } + + return cloned +} + +// cloneContextMessages 深拷贝消息切片,避免读时投影污染 runtime 持有的原始会话消息。 +func cloneContextMessages(messages []provider.Message) []provider.Message { + if len(messages) == 0 { + return nil + } + + cloned := make([]provider.Message, 0, len(messages)) + for _, message := range messages { + next := message + next.ToolCalls = append([]provider.ToolCall(nil), message.ToolCalls...) + cloned = append(cloned, next) + } + return cloned +} + +// isToolCallSpan 判断当前 span 是否是由 assistant tool call 起始的原子工具块。 +func isToolCallSpan(messages []provider.Message, span internalcompact.MessageSpan) bool { + if span.Start < 0 || span.Start >= len(messages) { + return false + } + message := messages[span.Start] + return message.Role == provider.RoleAssistant && len(message.ToolCalls) > 0 +} + +// compactableToolCallIDs 返回 assistant tool call 中可参与微压缩的调用 ID 集合。 +func compactableToolCallIDs(calls []provider.ToolCall) map[string]struct{} { + if len(calls) == 0 { + return nil + } + + ids := make(map[string]struct{}, len(calls)) + for _, call := range calls { + toolName := strings.TrimSpace(call.Name) + if _, ok := microCompactableTools[toolName]; !ok { + continue + } + callID := strings.TrimSpace(call.ID) + if callID == "" { + continue + } + ids[callID] = struct{}{} + } + if len(ids) == 0 { + return nil + } + return ids +} + +// shouldClearToolMessage 判断一条 tool 消息是否满足旧结果清理条件。 +func shouldClearToolMessage(message provider.Message, compactableIDs map[string]struct{}) bool { + if message.Role != provider.RoleTool || message.IsError { + return false + } + if compactableIDs == nil { + return false + } + if _, ok := compactableIDs[strings.TrimSpace(message.ToolCallID)]; !ok { + return false + } + + content := strings.TrimSpace(message.Content) + return content != "" && content != microCompactClearedMessage +} diff --git a/internal/context/microcompact_test.go b/internal/context/microcompact_test.go new file mode 100644 index 00000000..b0cce67e --- /dev/null +++ b/internal/context/microcompact_test.go @@ -0,0 +1,203 @@ +package context + +import ( + "testing" + + "neo-code/internal/provider" +) + +func TestMicroCompactMessagesClearsOlderCompactableToolResults(t *testing.T) { + t.Parallel() + + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "older user"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "old read result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-2", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-3", Name: "webfetch", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + {Role: provider.RoleUser, Content: "latest explicit instruction"}, + {Role: provider.RoleAssistant, Content: "current working reply"}, + } + + got := microCompactMessages(messages) + if len(got) != len(messages) { + t.Fatalf("expected message count to stay unchanged, got %d want %d", len(got), len(messages)) + } + if got[2].Content != microCompactClearedMessage { + t.Fatalf("expected oldest compactable tool result to be cleared, got %q", got[2].Content) + } + if got[4].Content != "recent bash result" { + t.Fatalf("expected recent compactable tool result to be retained, got %q", got[4].Content) + } + if got[6].Content != "latest webfetch result" { + t.Fatalf("expected latest compactable tool result to be retained, got %q", got[6].Content) + } + if messages[2].Content != "old read result" { + t.Fatalf("expected original slice to remain unchanged, got %q", messages[2].Content) + } +} + +func TestMicroCompactMessagesKeepsProtectedTailUntouched(t *testing.T) { + t.Parallel() + + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "older user"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-0", Name: "filesystem_grep", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-0", Content: "old grep result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "recent read result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-2", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + {Role: provider.RoleUser, Content: "latest explicit instruction"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-3", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-3", Content: "tail bash result"}, + } + + got := microCompactMessages(messages) + if got[2].Content != microCompactClearedMessage { + t.Fatalf("expected old tool result before protected tail to be cleared, got %q", got[2].Content) + } + if got[4].Content != "recent read result" { + t.Fatalf("expected recent tool result before protected tail to remain, got %q", got[4].Content) + } + if got[6].Content != "recent bash result" { + t.Fatalf("expected second recent tool result before protected tail to remain, got %q", got[6].Content) + } + if got[9].Content != "tail bash result" { + t.Fatalf("expected protected tail tool result to remain, got %q", got[9].Content) + } +} + +func TestMicroCompactMessagesSkipsNonCompactableErrorsAndOrphans(t *testing.T) { + t.Parallel() + + messages := []provider.Message{ + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "custom_tool", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "custom result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-2", Name: "filesystem_edit", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-2", Content: "edit failed", IsError: true}, + {Role: provider.RoleTool, ToolCallID: "orphan", Content: "orphan result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-3", Name: "filesystem_write_file", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-3", Content: microCompactClearedMessage}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-4", Name: "filesystem_grep", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-4", Content: ""}, + } + + got := microCompactMessages(messages) + if got[1].Content != "custom result" { + t.Fatalf("expected non-compactable tool result to remain, got %q", got[1].Content) + } + if got[3].Content != "edit failed" { + t.Fatalf("expected error tool result to remain, got %q", got[3].Content) + } + if got[4].Content != "orphan result" { + t.Fatalf("expected orphan tool result to remain, got %q", got[4].Content) + } + if got[6].Content != microCompactClearedMessage { + t.Fatalf("expected already cleared content to remain unchanged, got %q", got[6].Content) + } + if got[8].Content != "" { + t.Fatalf("expected empty tool result to remain empty, got %q", got[8].Content) + } +} + +func TestMicroCompactMessagesClearsOnlyCompactableResultsInMixedToolSpan(t *testing.T) { + t.Parallel() + + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "older user"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, + {ID: "call-2", Name: "custom_tool", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "read result"}, + {Role: provider.RoleTool, ToolCallID: "call-2", Content: "custom result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-3", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-3", Content: "recent bash result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-4", Name: "webfetch", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-4", Content: "latest webfetch result"}, + {Role: provider.RoleUser, Content: "latest explicit instruction"}, + {Role: provider.RoleAssistant, Content: "current reply"}, + } + + got := microCompactMessages(messages) + if got[2].Content != microCompactClearedMessage { + t.Fatalf("expected compactable tool result to be cleared, got %q", got[2].Content) + } + if got[3].Content != "custom result" { + t.Fatalf("expected non-compactable tool result in mixed span to remain, got %q", got[3].Content) + } + if len(got[1].ToolCalls) != 2 { + t.Fatalf("expected assistant tool call metadata to remain intact, got %+v", got[1].ToolCalls) + } +} From dea11ddd04ce0b76f05d599490484efcfb3276b2 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 5 Apr 2026 14:51:37 +0800 Subject: [PATCH 5/9] fix(context): avoid empty spans consuming micro compact budget --- internal/context/microcompact.go | 13 ++++++ internal/context/microcompact_test.go | 62 +++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/internal/context/microcompact.go b/internal/context/microcompact.go index 1eec70c0..10f5baba 100644 --- a/internal/context/microcompact.go +++ b/internal/context/microcompact.go @@ -48,6 +48,9 @@ func microCompactMessages(messages []provider.Message) []provider.Message { if len(compactableIDs) == 0 { continue } + if !hasCompactableToolContent(cloned, span, compactableIDs) { + continue + } if retainedCompactableSpans < microCompactRetainedToolSpans { retainedCompactableSpans++ continue @@ -111,6 +114,16 @@ func compactableToolCallIDs(calls []provider.ToolCall) map[string]struct{} { return ids } +// hasCompactableToolContent 判断工具块中是否存在会影响保留预算的有效工具结果内容。 +func hasCompactableToolContent(messages []provider.Message, span internalcompact.MessageSpan, compactableIDs map[string]struct{}) bool { + for messageIndex := span.Start + 1; messageIndex < span.End; messageIndex++ { + if shouldClearToolMessage(messages[messageIndex], compactableIDs) { + return true + } + } + return false +} + // shouldClearToolMessage 判断一条 tool 消息是否满足旧结果清理条件。 func shouldClearToolMessage(message provider.Message, compactableIDs map[string]struct{}) bool { if message.Role != provider.RoleTool || message.IsError { diff --git a/internal/context/microcompact_test.go b/internal/context/microcompact_test.go index b0cce67e..77b7b078 100644 --- a/internal/context/microcompact_test.go +++ b/internal/context/microcompact_test.go @@ -201,3 +201,65 @@ func TestMicroCompactMessagesClearsOnlyCompactableResultsInMixedToolSpan(t *test t.Fatalf("expected assistant tool call metadata to remain intact, got %+v", got[1].ToolCalls) } } + +func TestMicroCompactMessagesSkipsEmptyRecentSpansWhenCountingRetainedBudget(t *testing.T) { + t.Parallel() + + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "older user"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "older read result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-2", Name: "filesystem_grep", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-2", Content: "middle grep result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-3", Name: "filesystem_edit", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-3", Content: "near edit result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-4", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-4", Content: "", IsError: true}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-5", Name: "webfetch", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-5", Content: ""}, + {Role: provider.RoleUser, Content: "latest explicit instruction"}, + {Role: provider.RoleAssistant, Content: "current reply"}, + } + + got := microCompactMessages(messages) + if got[2].Content != microCompactClearedMessage { + t.Fatalf("expected oldest valid tool result to be cleared, got %q", got[2].Content) + } + if got[4].Content != "middle grep result" { + t.Fatalf("expected middle valid tool result to remain, got %q", got[4].Content) + } + if got[6].Content != "near edit result" { + t.Fatalf("expected nearer valid tool result to remain, got %q", got[6].Content) + } + if got[8].Content != "" { + t.Fatalf("expected error/empty tool result to remain unchanged, got %q", got[8].Content) + } + if got[10].Content != "" { + t.Fatalf("expected empty recent tool result to remain unchanged, got %q", got[10].Content) + } +} From d1aea902101531278259689a8216211116eb2dce Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 5 Apr 2026 15:12:20 +0800 Subject: [PATCH 6/9] =?UTF-8?q?fix(context):=20=E5=A2=9E=E5=8A=A0=20micro?= =?UTF-8?q?=20compact=20=E5=9B=9E=E9=80=80=E5=BC=80=E5=85=B3=E5=B9=B6?= =?UTF-8?q?=E6=94=B6=E6=95=9B=E5=B7=A5=E5=85=B7=E5=90=8D=E5=B8=B8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guides/configuration.md | 2 ++ internal/config/config_test.go | 10 ++++++ internal/config/loader.go | 3 ++ internal/config/model.go | 1 + internal/context/builder.go | 16 +++++++-- internal/context/builder_test.go | 54 ++++++++++++++++++++++++++++ internal/context/microcompact.go | 15 ++++---- internal/context/types.go | 6 ++++ internal/runtime/runtime.go | 3 ++ internal/runtime/runtime_test.go | 44 +++++++++++++++++++++++ internal/tools/bash/tool.go | 2 +- internal/tools/filesystem/helpers.go | 12 ++++--- internal/tools/names.go | 12 +++++++ internal/tools/webfetch/tool.go | 2 +- 14 files changed, 166 insertions(+), 16 deletions(-) create mode 100644 internal/tools/names.go diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index fed73849..155e29d9 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -315,6 +315,7 @@ context: manual_strategy: keep_recent manual_keep_recent_messages: 10 max_summary_chars: 1200 + micro_compact_disabled: false ``` ### 字段说明 @@ -324,5 +325,6 @@ context: | `context.compact.manual_strategy` | string | `keep_recent` | 手动 `/compact` 策略,可选 `keep_recent` / `full_replace` | | `context.compact.manual_keep_recent_messages` | int | `10` | `keep_recent` 模式下保留最近 N 条消息;会按 tool call 与 tool result 的原子块整体保留 | | `context.compact.max_summary_chars` | int | `1200` | compact summary 最大字符数 | +| `context.compact.micro_compact_disabled` | bool | `false` | 是否关闭默认启用的读时 micro compact;设为 `true` 可快速回退到仅 trim、不做旧工具结果清理 | 更多行为说明见 [context-compact.md](../context-compact.md)。 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4516c5e0..a2504ac2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -876,10 +876,14 @@ func TestCompactConfigDefaultsAndRoundTrip(t *testing.T) { if compactCfg.MaxSummaryChars != DefaultCompactMaxSummaryChars { t.Fatalf("expected max_summary_chars=%d, got %d", DefaultCompactMaxSummaryChars, compactCfg.MaxSummaryChars) } + if compactCfg.MicroCompactDisabled { + t.Fatalf("expected micro compact to be enabled by default") + } cfg.Context.Compact.ManualStrategy = CompactManualStrategyFullReplace cfg.Context.Compact.ManualKeepRecentMessages = 2 cfg.Context.Compact.MaxSummaryChars = 900 + cfg.Context.Compact.MicroCompactDisabled = true if err := loader.Save(context.Background(), cfg); err != nil { t.Fatalf("Save() error = %v", err) } @@ -894,6 +898,9 @@ func TestCompactConfigDefaultsAndRoundTrip(t *testing.T) { if strings.Contains(text, "manual_keep_recent_spans:") { t.Fatalf("expected persisted config to drop legacy manual_keep_recent_spans key, got:\n%s", text) } + if !strings.Contains(text, "micro_compact_disabled: true") { + t.Fatalf("expected persisted config to include micro_compact_disabled, got:\n%s", text) + } reloaded, err := loader.Load(context.Background()) if err != nil { @@ -908,6 +915,9 @@ func TestCompactConfigDefaultsAndRoundTrip(t *testing.T) { if reloaded.Context.Compact.MaxSummaryChars != 900 { t.Fatalf("expected max_summary_chars=900, got %d", reloaded.Context.Compact.MaxSummaryChars) } + if !reloaded.Context.Compact.MicroCompactDisabled { + t.Fatalf("expected micro_compact_disabled to persist") + } } func TestCompactConfigValidateFailures(t *testing.T) { diff --git a/internal/config/loader.go b/internal/config/loader.go index 6c8ef449..ea3a6c3e 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -41,6 +41,7 @@ type persistedCompactConfig struct { ManualStrategy string `yaml:"manual_strategy,omitempty"` ManualKeepRecentMessages int `yaml:"manual_keep_recent_messages,omitempty"` MaxSummaryChars int `yaml:"max_summary_chars,omitempty"` + MicroCompactDisabled bool `yaml:"micro_compact_disabled,omitempty"` } func NewLoader(baseDir string, defaults *Config) *Loader { @@ -217,6 +218,7 @@ func newPersistedContextConfig(cfg ContextConfig) persistedContextConfig { ManualStrategy: cfg.Compact.ManualStrategy, ManualKeepRecentMessages: cfg.Compact.ManualKeepRecentMessages, MaxSummaryChars: cfg.Compact.MaxSummaryChars, + MicroCompactDisabled: cfg.Compact.MicroCompactDisabled, }, } } @@ -228,6 +230,7 @@ func fromPersistedContextConfig(file persistedContextConfig, defaults ContextCon ManualStrategy: strings.TrimSpace(file.Compact.ManualStrategy), ManualKeepRecentMessages: file.Compact.ManualKeepRecentMessages, MaxSummaryChars: file.Compact.MaxSummaryChars, + MicroCompactDisabled: file.Compact.MicroCompactDisabled, }, } out.Compact.ApplyDefaults(defaults.Compact) diff --git a/internal/config/model.go b/internal/config/model.go index e8d1d3c9..75992d1c 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -70,6 +70,7 @@ type CompactConfig struct { ManualStrategy string `yaml:"manual_strategy,omitempty"` ManualKeepRecentMessages int `yaml:"manual_keep_recent_messages,omitempty"` MaxSummaryChars int `yaml:"max_summary_chars,omitempty"` + MicroCompactDisabled bool `yaml:"micro_compact_disabled,omitempty"` } type WebFetchConfig struct { diff --git a/internal/context/builder.go b/internal/context/builder.go index f1ca2c79..eeac9406 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -1,6 +1,10 @@ package context -import "context" +import ( + "context" + + "neo-code/internal/provider" +) // DefaultBuilder preserves the current runtime context-building behavior. type DefaultBuilder struct { @@ -43,6 +47,14 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu return BuildResult{ SystemPrompt: composeSystemPrompt(sections...), - Messages: microCompactMessages(trimPolicy.Trim(input.Messages)), + Messages: applyReadTimeContextProjection(trimPolicy.Trim(input.Messages), input.Compact), }, nil } + +// applyReadTimeContextProjection 负责在 provider 请求前按开关应用只读上下文投影,避免改写原始会话消息。 +func applyReadTimeContextProjection(messages []provider.Message, options CompactOptions) []provider.Message { + if options.DisableMicroCompact { + return cloneContextMessages(messages) + } + return microCompactMessages(messages) +} diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index 9f5098a0..8b72951d 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "strings" "testing" @@ -204,6 +205,59 @@ func TestDefaultBuilderBuildAppliesMicroCompactAfterTrim(t *testing.T) { } } +func TestDefaultBuilderBuildSkipsMicroCompactWhenDisabled(t *testing.T) { + t.Parallel() + + builder := &DefaultBuilder{ + promptSources: []promptSectionSource{ + stubPromptSectionSource{sections: []promptSection{{title: "Stub", content: "body"}}}, + }, + } + + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "older user"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "old read result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-2", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-3", Name: "webfetch", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + {Role: provider.RoleUser, Content: "latest explicit instruction"}, + {Role: provider.RoleAssistant, Content: "current reply"}, + } + + got, err := builder.Build(stdcontext.Background(), BuildInput{ + Messages: messages, + Compact: CompactOptions{ + DisableMicroCompact: true, + }, + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if !reflect.DeepEqual(got.Messages, messages) { + t.Fatalf("expected messages to remain unchanged when micro compact is disabled, got %+v", got.Messages) + } + if &got.Messages[2] == &messages[2] { + t.Fatalf("expected disabled path to still clone message slice") + } +} + func TestTrimMessagesPreservesToolPairs(t *testing.T) { t.Parallel() diff --git a/internal/context/microcompact.go b/internal/context/microcompact.go index 10f5baba..10a36cde 100644 --- a/internal/context/microcompact.go +++ b/internal/context/microcompact.go @@ -5,6 +5,7 @@ import ( "neo-code/internal/context/internalcompact" "neo-code/internal/provider" + "neo-code/internal/tools" ) const ( @@ -15,13 +16,13 @@ const ( ) var microCompactableTools = map[string]struct{}{ - "bash": {}, - "webfetch": {}, - "filesystem_read_file": {}, - "filesystem_grep": {}, - "filesystem_glob": {}, - "filesystem_edit": {}, - "filesystem_write_file": {}, + tools.ToolNameBash: {}, + tools.ToolNameWebFetch: {}, + tools.ToolNameFilesystemReadFile: {}, + tools.ToolNameFilesystemGrep: {}, + tools.ToolNameFilesystemGlob: {}, + tools.ToolNameFilesystemEdit: {}, + tools.ToolNameFilesystemWriteFile: {}, } // microCompactMessages 对裁剪后的消息做只读投影式微压缩,仅清理旧工具结果内容。 diff --git a/internal/context/types.go b/internal/context/types.go index 6eb516dd..2e406861 100644 --- a/internal/context/types.go +++ b/internal/context/types.go @@ -15,6 +15,7 @@ type Builder interface { type BuildInput struct { Messages []provider.Message Metadata Metadata + Compact CompactOptions } // BuildResult is the provider-facing context produced for a single round. @@ -22,3 +23,8 @@ type BuildResult struct { SystemPrompt string Messages []provider.Message } + +// CompactOptions controls read-time compact behavior inside the context builder. +type CompactOptions struct { + DisableMicroCompact bool +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index c95bbcd3..1ab3ec9b 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -157,6 +157,9 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { Provider: cfg.SelectedProvider, Model: cfg.CurrentModel, }, + Compact: agentcontext.CompactOptions{ + DisableMicroCompact: cfg.Context.Compact.MicroCompactDisabled, + }, }) if err != nil { return s.handleRunError(ctx, input.RunID, session.ID, err) diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index ff24909e..9cd8ed79 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -478,6 +478,9 @@ func TestServiceRunDelegatesToContextBuilder(t *testing.T) { if builder.lastInput.Metadata.Model == "" { t.Fatalf("expected model to be forwarded to builder metadata") } + if builder.lastInput.Compact.DisableMicroCompact { + t.Fatalf("expected micro compact to stay enabled by default") + } if len(builder.lastInput.Messages) != 1 || builder.lastInput.Messages[0].Content != "hello" { t.Fatalf("expected persisted session messages to be forwarded, got %+v", builder.lastInput.Messages) } @@ -492,6 +495,47 @@ func TestServiceRunDelegatesToContextBuilder(t *testing.T) { } } +func TestServiceRunCanDisableMicroCompactViaConfig(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Context.Compact.MicroCompactDisabled = true + return nil + }); err != nil { + t.Fatalf("update config: %v", err) + } + + store := newMemoryStore() + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + builder := &stubContextBuilder{ + buildFn: func(ctx context.Context, input agentcontext.BuildInput) (agentcontext.BuildResult, error) { + return agentcontext.BuildResult{ + SystemPrompt: "delegated prompt", + Messages: append([]provider.Message(nil), input.Messages...), + }, nil + }, + } + + scripted := &scriptedProvider{ + responses: []provider.ChatResponse{{ + Message: provider.Message{Role: provider.RoleAssistant, Content: "done"}, + FinishReason: "stop", + }}, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, builder) + if err := service.Run(context.Background(), UserInput{RunID: "run-disable-micro-compact", Content: "hello"}); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if !builder.lastInput.Compact.DisableMicroCompact { + t.Fatalf("expected config to disable micro compact in build input") + } +} + func TestServiceRunPersistsSessionProviderAndModel(t *testing.T) { t.Parallel() diff --git a/internal/tools/bash/tool.go b/internal/tools/bash/tool.go index f2559d97..5c67c719 100644 --- a/internal/tools/bash/tool.go +++ b/internal/tools/bash/tool.go @@ -45,7 +45,7 @@ func NewWithExecutor(root string, shell string, timeout time.Duration, executor } func (t *Tool) Name() string { - return "bash" + return tools.ToolNameBash } func (t *Tool) Description() string { diff --git a/internal/tools/filesystem/helpers.go b/internal/tools/filesystem/helpers.go index d681ed6e..929d74e1 100644 --- a/internal/tools/filesystem/helpers.go +++ b/internal/tools/filesystem/helpers.go @@ -4,14 +4,16 @@ import ( "os" "path/filepath" "strings" + + "neo-code/internal/tools" ) const ( - readFileToolName = "filesystem_read_file" - writeFileToolName = "filesystem_write_file" - grepToolName = "filesystem_grep" - globToolName = "filesystem_glob" - editToolName = "filesystem_edit" + readFileToolName = tools.ToolNameFilesystemReadFile + writeFileToolName = tools.ToolNameFilesystemWriteFile + grepToolName = tools.ToolNameFilesystemGrep + globToolName = tools.ToolNameFilesystemGlob + editToolName = tools.ToolNameFilesystemEdit ) func effectiveRoot(defaultRoot string, workdir string) string { diff --git a/internal/tools/names.go b/internal/tools/names.go new file mode 100644 index 00000000..833430f7 --- /dev/null +++ b/internal/tools/names.go @@ -0,0 +1,12 @@ +package tools + +// Tool name constants are shared across tool implementations, context policies, and tests. +const ( + ToolNameBash = "bash" + ToolNameWebFetch = "webfetch" + ToolNameFilesystemReadFile = "filesystem_read_file" + ToolNameFilesystemWriteFile = "filesystem_write_file" + ToolNameFilesystemGrep = "filesystem_grep" + ToolNameFilesystemGlob = "filesystem_glob" + ToolNameFilesystemEdit = "filesystem_edit" +) diff --git a/internal/tools/webfetch/tool.go b/internal/tools/webfetch/tool.go index efa5474f..5a77c2b2 100644 --- a/internal/tools/webfetch/tool.go +++ b/internal/tools/webfetch/tool.go @@ -16,7 +16,7 @@ import ( ) const ( - toolName = "webfetch" + toolName = tools.ToolNameWebFetch htmlContentType = "text/html" xhtmlContentType = "application/xhtml+xml" reasonInvalidArguments = "invalid arguments" From d17cd209b86c9bf42ebdd09237d9ab94ac8b254e Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Mon, 6 Apr 2026 11:43:16 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=20micro=20compact?= =?UTF-8?q?=20=E5=B7=A5=E5=85=B7=E7=AD=96=E7=95=A5=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/context-compact.md | 6 ++ docs/guides/configuration.md | 2 + internal/app/bootstrap.go | 2 +- internal/app/bootstrap_test.go | 3 + internal/context/builder.go | 19 +++++-- internal/context/builder_test.go | 48 ++++++++++++++++ internal/context/microcompact.go | 29 +++++----- internal/context/microcompact_test.go | 63 ++++++++++++++++++--- internal/context/types.go | 6 ++ internal/runtime/runtime.go | 2 +- internal/runtime/runtime_test.go | 75 +++++++++++++++++++++++++ internal/tools/bash/tool.go | 5 ++ internal/tools/filesystem/edit.go | 5 ++ internal/tools/filesystem/glob.go | 5 ++ internal/tools/filesystem/grep.go | 5 ++ internal/tools/filesystem/read_file.go | 5 ++ internal/tools/filesystem/write_file.go | 5 ++ internal/tools/manager.go | 16 ++++++ internal/tools/manager_test.go | 2 + internal/tools/micro_compact_policy.go | 11 ++++ internal/tools/registry.go | 30 +++++++++- internal/tools/registry_test.go | 26 +++++++++ internal/tools/types.go | 1 + internal/tools/webfetch/tool.go | 5 ++ 24 files changed, 345 insertions(+), 31 deletions(-) create mode 100644 internal/tools/micro_compact_policy.go diff --git a/docs/context-compact.md b/docs/context-compact.md index b55c082a..43e43fa1 100644 --- a/docs/context-compact.md +++ b/docs/context-compact.md @@ -19,6 +19,7 @@ context: manual_strategy: keep_recent manual_keep_recent_messages: 10 max_summary_chars: 1200 + micro_compact_disabled: false ``` - `manual_strategy` @@ -27,6 +28,11 @@ context: 在 `keep_recent` 模式下保留最近消息数量,并按 tool call 与 tool result 的原子块整体保留。 - `max_summary_chars` 控制 compact summary 的最大字符数。 +- `micro_compact_disabled` + 控制是否关闭默认启用的读时 micro compact;设为 `true` 时会回退为仅 trim、不清理旧 tool result。 + +新增工具时,micro compact 策略不再由 `context` 层静态白名单维护,而是由 `internal/tools` 中的工具实现声明。 +默认情况下,已注册工具都会参与 micro compact;只有显式声明保留历史结果的工具才会跳过旧结果清理。 ## 执行链路 diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 155e29d9..26a5800e 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -327,4 +327,6 @@ context: | `context.compact.max_summary_chars` | int | `1200` | compact summary 最大字符数 | | `context.compact.micro_compact_disabled` | bool | `false` | 是否关闭默认启用的读时 micro compact;设为 `true` 可快速回退到仅 trim、不做旧工具结果清理 | +新增工具默认会参与 micro compact;如果某个工具的历史结果必须保留,需要在 `internal/tools` 的工具实现中显式声明保留策略。 + 更多行为说明见 [context-compact.md](../context-compact.md)。 diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index d87241ea..fa23fed3 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -68,7 +68,7 @@ func NewProgram(ctx context.Context) (*tea.Program, error) { toolManager, sessionStore, providerRegistry, - agentcontext.NewBuilder(), + agentcontext.NewBuilderWithToolPolicies(toolRegistry), ) tuiApp, err := tui.New(&cfg, manager, runtimeSvc, providerSelection) diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index bb204cbd..d3d8e340 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -218,6 +218,9 @@ type stubToolForBootstrap struct { func (s stubToolForBootstrap) Name() string { return s.name } func (s stubToolForBootstrap) Description() string { return "stub" } func (s stubToolForBootstrap) Schema() map[string]any { return map[string]any{"type": "object"} } +func (s stubToolForBootstrap) MicroCompactPolicy() tools.MicroCompactPolicy { + return tools.MicroCompactPolicyCompact +} func (s stubToolForBootstrap) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) { return tools.ToolResult{Name: s.name, Content: s.content}, nil } diff --git a/internal/context/builder.go b/internal/context/builder.go index eeac9406..db2e77c1 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -8,12 +8,18 @@ import ( // DefaultBuilder preserves the current runtime context-building behavior. type DefaultBuilder struct { - promptSources []promptSectionSource - trimPolicy messageTrimPolicy + promptSources []promptSectionSource + trimPolicy messageTrimPolicy + microCompactPolicies MicroCompactPolicySource } // NewBuilder returns the default context builder implementation. func NewBuilder() Builder { + return NewBuilderWithToolPolicies(nil) +} + +// NewBuilderWithToolPolicies 返回带工具 micro compact 策略源的默认上下文构建器。 +func NewBuilderWithToolPolicies(policies MicroCompactPolicySource) Builder { systemSource := &systemStateSource{gitRunner: runGitCommand} return &DefaultBuilder{ promptSources: []promptSectionSource{ @@ -21,7 +27,8 @@ func NewBuilder() Builder { &projectRulesSource{}, systemSource, }, - trimPolicy: spanMessageTrimPolicy{}, + trimPolicy: spanMessageTrimPolicy{}, + microCompactPolicies: policies, } } @@ -47,14 +54,14 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu return BuildResult{ SystemPrompt: composeSystemPrompt(sections...), - Messages: applyReadTimeContextProjection(trimPolicy.Trim(input.Messages), input.Compact), + Messages: applyReadTimeContextProjection(trimPolicy.Trim(input.Messages), input.Compact, b.microCompactPolicies), }, nil } // applyReadTimeContextProjection 负责在 provider 请求前按开关应用只读上下文投影,避免改写原始会话消息。 -func applyReadTimeContextProjection(messages []provider.Message, options CompactOptions) []provider.Message { +func applyReadTimeContextProjection(messages []provider.Message, options CompactOptions, policies MicroCompactPolicySource) []provider.Message { if options.DisableMicroCompact { return cloneContextMessages(messages) } - return microCompactMessages(messages) + return microCompactMessagesWithPolicies(messages, policies) } diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index 8b72951d..8254a25f 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -11,6 +11,7 @@ import ( "neo-code/internal/context/internalcompact" "neo-code/internal/provider" + "neo-code/internal/tools" ) type stubPromptSectionSource struct { @@ -258,6 +259,53 @@ func TestDefaultBuilderBuildSkipsMicroCompactWhenDisabled(t *testing.T) { } } +func TestDefaultBuilderBuildHonorsToolMicroCompactPolicies(t *testing.T) { + t.Parallel() + + builder := &DefaultBuilder{ + promptSources: []promptSectionSource{ + stubPromptSectionSource{sections: []promptSection{{title: "Stub", content: "body"}}}, + }, + microCompactPolicies: stubMicroCompactPolicySource{ + "custom_tool": tools.MicroCompactPolicyPreserveHistory, + }, + } + + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "older user"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "custom_tool", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "old custom result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-2", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-3", Name: "webfetch", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + {Role: provider.RoleUser, Content: "latest explicit instruction"}, + } + + got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages}) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if got.Messages[2].Content != "old custom result" { + t.Fatalf("expected preserved tool result to remain, got %q", got.Messages[2].Content) + } +} + func TestTrimMessagesPreservesToolPairs(t *testing.T) { t.Parallel() diff --git a/internal/context/microcompact.go b/internal/context/microcompact.go index 10a36cde..e90a5d24 100644 --- a/internal/context/microcompact.go +++ b/internal/context/microcompact.go @@ -15,18 +15,13 @@ const ( microCompactRetainedToolSpans = 2 ) -var microCompactableTools = map[string]struct{}{ - tools.ToolNameBash: {}, - tools.ToolNameWebFetch: {}, - tools.ToolNameFilesystemReadFile: {}, - tools.ToolNameFilesystemGrep: {}, - tools.ToolNameFilesystemGlob: {}, - tools.ToolNameFilesystemEdit: {}, - tools.ToolNameFilesystemWriteFile: {}, -} - // microCompactMessages 对裁剪后的消息做只读投影式微压缩,仅清理旧工具结果内容。 func microCompactMessages(messages []provider.Message) []provider.Message { + return microCompactMessagesWithPolicies(messages, nil) +} + +// microCompactMessagesWithPolicies 按工具策略对裁剪后的消息做只读投影式微压缩。 +func microCompactMessagesWithPolicies(messages []provider.Message, policies MicroCompactPolicySource) []provider.Message { cloned := cloneContextMessages(messages) if len(cloned) == 0 { return cloned @@ -45,7 +40,7 @@ func microCompactMessages(messages []provider.Message) []provider.Message { continue } - compactableIDs := compactableToolCallIDs(cloned[span.Start].ToolCalls) + compactableIDs := compactableToolCallIDs(cloned[span.Start].ToolCalls, policies) if len(compactableIDs) == 0 { continue } @@ -92,7 +87,7 @@ func isToolCallSpan(messages []provider.Message, span internalcompact.MessageSpa } // compactableToolCallIDs 返回 assistant tool call 中可参与微压缩的调用 ID 集合。 -func compactableToolCallIDs(calls []provider.ToolCall) map[string]struct{} { +func compactableToolCallIDs(calls []provider.ToolCall, policies MicroCompactPolicySource) map[string]struct{} { if len(calls) == 0 { return nil } @@ -100,7 +95,7 @@ func compactableToolCallIDs(calls []provider.ToolCall) map[string]struct{} { ids := make(map[string]struct{}, len(calls)) for _, call := range calls { toolName := strings.TrimSpace(call.Name) - if _, ok := microCompactableTools[toolName]; !ok { + if !toolParticipatesInMicroCompact(toolName, policies) { continue } callID := strings.TrimSpace(call.ID) @@ -115,6 +110,14 @@ func compactableToolCallIDs(calls []provider.ToolCall) map[string]struct{} { return ids } +// toolParticipatesInMicroCompact 判断工具是否应参与 micro compact;未知工具默认视为可压缩。 +func toolParticipatesInMicroCompact(toolName string, policies MicroCompactPolicySource) bool { + if policies == nil { + return true + } + return policies.MicroCompactPolicy(toolName) != tools.MicroCompactPolicyPreserveHistory +} + // hasCompactableToolContent 判断工具块中是否存在会影响保留预算的有效工具结果内容。 func hasCompactableToolContent(messages []provider.Message, span internalcompact.MessageSpan, compactableIDs map[string]struct{}) bool { for messageIndex := span.Start + 1; messageIndex < span.End; messageIndex++ { diff --git a/internal/context/microcompact_test.go b/internal/context/microcompact_test.go index 77b7b078..22a95fa4 100644 --- a/internal/context/microcompact_test.go +++ b/internal/context/microcompact_test.go @@ -4,8 +4,18 @@ import ( "testing" "neo-code/internal/provider" + "neo-code/internal/tools" ) +type stubMicroCompactPolicySource map[string]tools.MicroCompactPolicy + +func (s stubMicroCompactPolicySource) MicroCompactPolicy(name string) tools.MicroCompactPolicy { + if policy, ok := s[name]; ok { + return policy + } + return tools.MicroCompactPolicyCompact +} + func TestMicroCompactMessagesClearsOlderCompactableToolResults(t *testing.T) { t.Parallel() @@ -105,7 +115,7 @@ func TestMicroCompactMessagesKeepsProtectedTailUntouched(t *testing.T) { } } -func TestMicroCompactMessagesSkipsNonCompactableErrorsAndOrphans(t *testing.T) { +func TestMicroCompactMessagesKeepsPreservedToolsErrorsAndOrphans(t *testing.T) { t.Parallel() messages := []provider.Message{ @@ -140,9 +150,11 @@ func TestMicroCompactMessagesSkipsNonCompactableErrorsAndOrphans(t *testing.T) { {Role: provider.RoleTool, ToolCallID: "call-4", Content: ""}, } - got := microCompactMessages(messages) + got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ + "custom_tool": tools.MicroCompactPolicyPreserveHistory, + }) if got[1].Content != "custom result" { - t.Fatalf("expected non-compactable tool result to remain, got %q", got[1].Content) + t.Fatalf("expected preserved tool result to remain, got %q", got[1].Content) } if got[3].Content != "edit failed" { t.Fatalf("expected error tool result to remain, got %q", got[3].Content) @@ -158,7 +170,7 @@ func TestMicroCompactMessagesSkipsNonCompactableErrorsAndOrphans(t *testing.T) { } } -func TestMicroCompactMessagesClearsOnlyCompactableResultsInMixedToolSpan(t *testing.T) { +func TestMicroCompactMessagesClearsOnlyNonPreservedResultsInMixedToolSpan(t *testing.T) { t.Parallel() messages := []provider.Message{ @@ -190,18 +202,55 @@ func TestMicroCompactMessagesClearsOnlyCompactableResultsInMixedToolSpan(t *test {Role: provider.RoleAssistant, Content: "current reply"}, } - got := microCompactMessages(messages) + got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ + "custom_tool": tools.MicroCompactPolicyPreserveHistory, + }) if got[2].Content != microCompactClearedMessage { - t.Fatalf("expected compactable tool result to be cleared, got %q", got[2].Content) + t.Fatalf("expected default compactable tool result to be cleared, got %q", got[2].Content) } if got[3].Content != "custom result" { - t.Fatalf("expected non-compactable tool result in mixed span to remain, got %q", got[3].Content) + t.Fatalf("expected preserved tool result in mixed span to remain, got %q", got[3].Content) } if len(got[1].ToolCalls) != 2 { t.Fatalf("expected assistant tool call metadata to remain intact, got %+v", got[1].ToolCalls) } } +func TestMicroCompactMessagesTreatsNewToolsAsCompactableByDefault(t *testing.T) { + t.Parallel() + + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "older user"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "repo_search", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "old repo search result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-2", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-3", Name: "webfetch", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + {Role: provider.RoleUser, Content: "latest explicit instruction"}, + } + + got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}) + if got[2].Content != microCompactClearedMessage { + t.Fatalf("expected new tool result to be compacted by default, got %q", got[2].Content) + } +} + func TestMicroCompactMessagesSkipsEmptyRecentSpansWhenCountingRetainedBudget(t *testing.T) { t.Parallel() diff --git a/internal/context/types.go b/internal/context/types.go index 2e406861..e1bb0c93 100644 --- a/internal/context/types.go +++ b/internal/context/types.go @@ -4,6 +4,7 @@ import ( "context" "neo-code/internal/provider" + "neo-code/internal/tools" ) // Builder builds the provider-facing context for a single model round. @@ -24,6 +25,11 @@ type BuildResult struct { Messages []provider.Message } +// MicroCompactPolicySource 定义 context 读取工具 micro compact 策略的最小依赖。 +type MicroCompactPolicySource interface { + MicroCompactPolicy(name string) tools.MicroCompactPolicy +} + // CompactOptions controls read-time compact behavior inside the context builder. type CompactOptions struct { DisableMicroCompact bool diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 1ab3ec9b..55402042 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -86,7 +86,7 @@ func NewWithFactory( toolManager = tools.NewRegistry() } if contextBuilder == nil { - contextBuilder = agentcontext.NewBuilder() + contextBuilder = agentcontext.NewBuilderWithToolPolicies(toolManager) } return &Service{ diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 9cd8ed79..7b22a68d 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -144,6 +144,7 @@ type stubTool struct { content string isError bool err error + policy tools.MicroCompactPolicy callCount int lastInput tools.ToolCallInput executeFn func(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) @@ -161,6 +162,10 @@ func (t *stubTool) Schema() map[string]any { return map[string]any{"type": "object"} } +func (t *stubTool) MicroCompactPolicy() tools.MicroCompactPolicy { + return t.policy +} + func (t *stubTool) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { t.callCount++ t.lastInput = input @@ -202,6 +207,7 @@ type stubToolManager struct { result tools.ToolResult err error listErr error + policies map[string]tools.MicroCompactPolicy listCalls int executeCalls int lastInput tools.ToolCallInput @@ -218,6 +224,13 @@ func (m *stubToolManager) ListAvailableSpecs(ctx context.Context, input tools.Sp return append([]provider.ToolSpec(nil), m.specs...), nil } +func (m *stubToolManager) MicroCompactPolicy(name string) tools.MicroCompactPolicy { + if policy, ok := m.policies[name]; ok { + return policy + } + return tools.MicroCompactPolicyCompact +} + func (m *stubToolManager) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { m.executeCalls++ m.lastInput = input @@ -566,6 +579,68 @@ func TestServiceRunPersistsSessionProviderAndModel(t *testing.T) { } } +func TestServiceRunDefaultBuilderUsesToolManagerMicroCompactPolicies(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "preserve_tool", content: "default", policy: tools.MicroCompactPolicyPreserveHistory}) + registry.Register(&stubTool{name: "bash", content: "default"}) + registry.Register(&stubTool{name: "webfetch", content: "default"}) + + session := newSession("preserve history") + session.ID = "session-preserve-history" + session.Messages = []provider.Message{ + {Role: provider.RoleUser, Content: "older user"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "preserve_tool", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "preserved result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-2", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-3", Name: "webfetch", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + } + store.sessions[session.ID] = cloneSession(session) + + scripted := &scriptedProvider{ + responses: []provider.ChatResponse{{ + Message: provider.Message{Role: provider.RoleAssistant, Content: "done"}, + FinishReason: "stop", + }}, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) + if err := service.Run(context.Background(), UserInput{ + SessionID: session.ID, + RunID: "run-preserve-history-policy", + Content: "latest explicit instruction", + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if len(scripted.requests) != 1 { + t.Fatalf("expected 1 provider request, got %d", len(scripted.requests)) + } + if got := scripted.requests[0].Messages[2].Content; got != "preserved result" { + t.Fatalf("expected preserved tool result to remain visible, got %q", got) + } +} + func TestServiceRunFailurePreservesExistingSessionProviderAndModel(t *testing.T) { t.Parallel() diff --git a/internal/tools/bash/tool.go b/internal/tools/bash/tool.go index 5c67c719..e02bce21 100644 --- a/internal/tools/bash/tool.go +++ b/internal/tools/bash/tool.go @@ -69,6 +69,11 @@ func (t *Tool) Schema() map[string]any { } } +// MicroCompactPolicy 声明 bash 工具的历史结果默认参与 micro compact 清理。 +func (t *Tool) MicroCompactPolicy() tools.MicroCompactPolicy { + return tools.MicroCompactPolicyCompact +} + func (t *Tool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) { var in input if err := json.Unmarshal(call.Arguments, &in); err != nil { diff --git a/internal/tools/filesystem/edit.go b/internal/tools/filesystem/edit.go index 069830f7..631e1082 100644 --- a/internal/tools/filesystem/edit.go +++ b/internal/tools/filesystem/edit.go @@ -55,6 +55,11 @@ func (t *EditTool) Schema() map[string]any { } } +// MicroCompactPolicy 声明编辑工具的历史结果默认参与 micro compact 清理。 +func (t *EditTool) MicroCompactPolicy() tools.MicroCompactPolicy { + return tools.MicroCompactPolicyCompact +} + func (t *EditTool) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { var args editInput if err := json.Unmarshal(input.Arguments, &args); err != nil { diff --git a/internal/tools/filesystem/glob.go b/internal/tools/filesystem/glob.go index fb5c1c74..d2858f3d 100644 --- a/internal/tools/filesystem/glob.go +++ b/internal/tools/filesystem/glob.go @@ -51,6 +51,11 @@ func (t *GlobTool) Schema() map[string]any { } } +// MicroCompactPolicy 声明 glob 工具的历史结果默认参与 micro compact 清理。 +func (t *GlobTool) MicroCompactPolicy() tools.MicroCompactPolicy { + return tools.MicroCompactPolicyCompact +} + func (t *GlobTool) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { var args globInput if err := json.Unmarshal(input.Arguments, &args); err != nil { diff --git a/internal/tools/filesystem/grep.go b/internal/tools/filesystem/grep.go index 3c993016..5ef5c8fc 100644 --- a/internal/tools/filesystem/grep.go +++ b/internal/tools/filesystem/grep.go @@ -60,6 +60,11 @@ func (t *GrepTool) Schema() map[string]any { } } +// MicroCompactPolicy 声明 grep 工具的历史结果默认参与 micro compact 清理。 +func (t *GrepTool) MicroCompactPolicy() tools.MicroCompactPolicy { + return tools.MicroCompactPolicyCompact +} + func (t *GrepTool) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { var args grepInput if err := json.Unmarshal(input.Arguments, &args); err != nil { diff --git a/internal/tools/filesystem/read_file.go b/internal/tools/filesystem/read_file.go index 030b0b4b..7919fdeb 100644 --- a/internal/tools/filesystem/read_file.go +++ b/internal/tools/filesystem/read_file.go @@ -47,6 +47,11 @@ func (t *ReadFileTool) Schema() map[string]any { } } +// MicroCompactPolicy 声明读文件工具的历史结果默认参与 micro compact 清理。 +func (t *ReadFileTool) MicroCompactPolicy() tools.MicroCompactPolicy { + return tools.MicroCompactPolicyCompact +} + func (t *ReadFileTool) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { var args readFileInput if err := json.Unmarshal(input.Arguments, &args); err != nil { diff --git a/internal/tools/filesystem/write_file.go b/internal/tools/filesystem/write_file.go index 1b5069f6..f68c6548 100644 --- a/internal/tools/filesystem/write_file.go +++ b/internal/tools/filesystem/write_file.go @@ -50,6 +50,11 @@ func (t *WriteFileTool) Schema() map[string]any { } } +// MicroCompactPolicy 声明写文件工具的历史结果默认参与 micro compact 清理。 +func (t *WriteFileTool) MicroCompactPolicy() tools.MicroCompactPolicy { + return tools.MicroCompactPolicyCompact +} + func (t *WriteFileTool) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { var args writeFileInput if err := json.Unmarshal(input.Arguments, &args); err != nil { diff --git a/internal/tools/manager.go b/internal/tools/manager.go index 4c099c4c..8dd7870a 100644 --- a/internal/tools/manager.go +++ b/internal/tools/manager.go @@ -19,6 +19,7 @@ type SpecListInput struct { // Manager is the runtime-facing tool execution and schema exposure boundary. type Manager interface { ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]provider.ToolSpec, error) + MicroCompactPolicy(name string) MicroCompactPolicy Execute(ctx context.Context, input ToolCallInput) (ToolResult, error) } @@ -29,6 +30,10 @@ type Executor interface { Supports(name string) bool } +type microCompactPolicyExecutor interface { + MicroCompactPolicy(name string) MicroCompactPolicy +} + // WorkspaceSandbox enforces workspace-oriented constraints before execution. type WorkspaceSandbox interface { Check(ctx context.Context, action security.Action) (*security.WorkspaceExecutionPlan, error) @@ -151,6 +156,17 @@ func (m *DefaultManager) ListAvailableSpecs(ctx context.Context, input SpecListI return m.executor.ListAvailableSpecs(ctx, input) } +// MicroCompactPolicy 返回工具的 micro compact 策略;无法判断时按默认可压缩处理。 +func (m *DefaultManager) MicroCompactPolicy(name string) MicroCompactPolicy { + if m == nil || m.executor == nil { + return MicroCompactPolicyCompact + } + if source, ok := m.executor.(microCompactPolicyExecutor); ok { + return source.MicroCompactPolicy(name) + } + return MicroCompactPolicyCompact +} + // 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/manager_test.go b/internal/tools/manager_test.go index a4a2fe98..e2efd02f 100644 --- a/internal/tools/manager_test.go +++ b/internal/tools/manager_test.go @@ -25,6 +25,8 @@ func (t *managerStubTool) Description() string { return "stub tool" } func (t *managerStubTool) Schema() map[string]any { return map[string]any{"type": "object"} } +func (t *managerStubTool) MicroCompactPolicy() MicroCompactPolicy { return MicroCompactPolicyCompact } + func (t *managerStubTool) Execute(ctx context.Context, call ToolCallInput) (ToolResult, error) { t.callCount++ t.lastCall = call diff --git a/internal/tools/micro_compact_policy.go b/internal/tools/micro_compact_policy.go new file mode 100644 index 00000000..9225e9bc --- /dev/null +++ b/internal/tools/micro_compact_policy.go @@ -0,0 +1,11 @@ +package tools + +// MicroCompactPolicy 描述工具历史结果参与 read-time micro compact 的策略。 +type MicroCompactPolicy string + +const ( + // MicroCompactPolicyCompact 表示工具历史结果默认参与 micro compact 清理。 + MicroCompactPolicyCompact MicroCompactPolicy = "" + // MicroCompactPolicyPreserveHistory 表示工具历史结果应显式保留,不参与 micro compact 清理。 + MicroCompactPolicyPreserveHistory MicroCompactPolicy = "preserve_history" +) diff --git a/internal/tools/registry.go b/internal/tools/registry.go index c057ee86..66a9fc33 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -10,12 +10,14 @@ import ( ) type Registry struct { - tools map[string]Tool + tools map[string]Tool + microCompactPolicies map[string]MicroCompactPolicy } func NewRegistry() *Registry { return &Registry{ - tools: map[string]Tool{}, + tools: map[string]Tool{}, + microCompactPolicies: map[string]MicroCompactPolicy{}, } } @@ -23,7 +25,14 @@ func (r *Registry) Register(tool Tool) { if tool == nil { return } - r.tools[strings.ToLower(tool.Name())] = tool + name := strings.ToLower(tool.Name()) + r.tools[name] = tool + switch tool.MicroCompactPolicy() { + case MicroCompactPolicyPreserveHistory: + r.microCompactPolicies[name] = MicroCompactPolicyPreserveHistory + default: + r.microCompactPolicies[name] = MicroCompactPolicyCompact + } } func (r *Registry) Get(name string) (Tool, error) { @@ -40,6 +49,21 @@ func (r *Registry) Supports(name string) bool { return err == nil } +// MicroCompactPolicy 返回指定工具名的 micro compact 策略;未知工具按默认可压缩处理。 +func (r *Registry) MicroCompactPolicy(name string) MicroCompactPolicy { + if r == nil { + return MicroCompactPolicyCompact + } + policy, ok := r.microCompactPolicies[strings.ToLower(strings.TrimSpace(name))] + if !ok { + return MicroCompactPolicyCompact + } + if policy == MicroCompactPolicyPreserveHistory { + return MicroCompactPolicyPreserveHistory + } + return MicroCompactPolicyCompact +} + func (r *Registry) GetSpecs() []provider.ToolSpec { names := make([]string, 0, len(r.tools)) for name := range r.tools { diff --git a/internal/tools/registry_test.go b/internal/tools/registry_test.go index e8d2db35..ec09852a 100644 --- a/internal/tools/registry_test.go +++ b/internal/tools/registry_test.go @@ -11,6 +11,7 @@ type stubTool struct { name string description string schema map[string]any + policy MicroCompactPolicy result ToolResult err error } @@ -20,6 +21,9 @@ func (s stubTool) Description() string { return s.description } func (s stubTool) Schema() map[string]any { return s.schema } +func (s stubTool) MicroCompactPolicy() MicroCompactPolicy { + return s.policy +} func (s stubTool) Execute(ctx context.Context, call ToolCallInput) (ToolResult, error) { return s.result, s.err } @@ -159,6 +163,12 @@ func TestRegistryHelpers(t *testing.T) { if registry.Supports("missing") { t.Fatalf("did not expect registry to support missing tool") } + if registry.MicroCompactPolicy("a_tool") != MicroCompactPolicyCompact { + t.Fatalf("expected compact policy default for a_tool") + } + if registry.MicroCompactPolicy("missing") != MicroCompactPolicyCompact { + t.Fatalf("expected compact policy default for unknown tool") + } schemas := registry.ListSchemas() if len(schemas) != 1 || schemas[0].Name != "a_tool" { @@ -180,3 +190,19 @@ func TestRegistryHelpers(t *testing.T) { t.Fatalf("expected context canceled, got %v", err) } } + +func TestRegistryMicroCompactPolicyPreserveHistory(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + registry.Register(stubTool{ + name: "custom_tool", + description: "preserve history", + schema: map[string]any{"type": "object"}, + policy: MicroCompactPolicyPreserveHistory, + }) + + if got := registry.MicroCompactPolicy("custom_tool"); got != MicroCompactPolicyPreserveHistory { + t.Fatalf("expected preserve history policy, got %q", got) + } +} diff --git a/internal/tools/types.go b/internal/tools/types.go index 39147ee9..4a0a0a32 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -11,6 +11,7 @@ type Tool interface { Name() string Description() string Schema() map[string]any + MicroCompactPolicy() MicroCompactPolicy Execute(ctx context.Context, call ToolCallInput) (ToolResult, error) } diff --git a/internal/tools/webfetch/tool.go b/internal/tools/webfetch/tool.go index 5a77c2b2..83daa3f5 100644 --- a/internal/tools/webfetch/tool.go +++ b/internal/tools/webfetch/tool.go @@ -87,6 +87,11 @@ func (t *Tool) Schema() map[string]any { } } +// MicroCompactPolicy 声明 webfetch 工具的历史结果默认参与 micro compact 清理。 +func (t *Tool) MicroCompactPolicy() tools.MicroCompactPolicy { + return tools.MicroCompactPolicyCompact +} + func (t *Tool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) { in, err := decodeInput(call.Arguments) if err != nil { From 098a26338f0f70328da90aaf886aa6f89c45fc83 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Mon, 6 Apr 2026 13:45:05 +0800 Subject: [PATCH 8/9] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=20micro=20compac?= =?UTF-8?q?t=20=E7=AD=96=E7=95=A5=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/context/builder_test.go | 42 +++++++++++++++++++++ internal/runtime/runtime_test.go | 63 ++++++++++++++++++++++++++++++++ internal/tools/manager_test.go | 59 +++++++++++++++++++++++++++++- 3 files changed, 163 insertions(+), 1 deletion(-) diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index 8254a25f..e76d4389 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -306,6 +306,48 @@ func TestDefaultBuilderBuildHonorsToolMicroCompactPolicies(t *testing.T) { } } +func TestNewBuilderWithToolPoliciesUsesProvidedPolicySource(t *testing.T) { + t.Parallel() + + builder := NewBuilderWithToolPolicies(stubMicroCompactPolicySource{ + "custom_tool": tools.MicroCompactPolicyPreserveHistory, + }) + + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "older user"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "custom_tool", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "old custom result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-2", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-3", Name: "webfetch", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + {Role: provider.RoleUser, Content: "latest explicit instruction"}, + } + + got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages}) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if got.Messages[2].Content != "old custom result" { + t.Fatalf("expected preserved tool result to remain, got %q", got.Messages[2].Content) + } +} + func TestTrimMessagesPreservesToolPairs(t *testing.T) { t.Parallel() diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 7b22a68d..35d782e7 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -641,6 +641,69 @@ func TestServiceRunDefaultBuilderUsesToolManagerMicroCompactPolicies(t *testing. } } +func TestServiceRunDefaultBuilderUsesGenericToolManagerMicroCompactPolicies(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + toolManager := &stubToolManager{ + policies: map[string]tools.MicroCompactPolicy{ + "preserve_tool": tools.MicroCompactPolicyPreserveHistory, + }, + } + + session := newSession("preserve history by manager") + session.ID = "session-preserve-history-manager" + session.Messages = []provider.Message{ + {Role: provider.RoleUser, Content: "older user"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "preserve_tool", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "preserved result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-2", Name: "bash", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-3", Name: "webfetch", Arguments: "{}"}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + } + store.sessions[session.ID] = cloneSession(session) + + scripted := &scriptedProvider{ + responses: []provider.ChatResponse{{ + Message: provider.Message{Role: provider.RoleAssistant, Content: "done"}, + FinishReason: "stop", + }}, + } + + service := NewWithFactory(manager, toolManager, store, &scriptedProviderFactory{provider: scripted}, nil) + if err := service.Run(context.Background(), UserInput{ + SessionID: session.ID, + RunID: "run-preserve-history-generic-manager", + Content: "latest explicit instruction", + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if len(scripted.requests) != 1 { + t.Fatalf("expected 1 provider request, got %d", len(scripted.requests)) + } + if got := scripted.requests[0].Messages[2].Content; got != "preserved result" { + t.Fatalf("expected preserved tool result to remain visible, got %q", got) + } +} + func TestServiceRunFailurePreservesExistingSessionProviderAndModel(t *testing.T) { t.Parallel() diff --git a/internal/tools/manager_test.go b/internal/tools/manager_test.go index e2efd02f..9a1cac9e 100644 --- a/internal/tools/manager_test.go +++ b/internal/tools/manager_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "neo-code/internal/provider" "neo-code/internal/security" ) @@ -15,6 +16,7 @@ type managerStubTool struct { name string content string err error + policy MicroCompactPolicy callCount int lastCall ToolCallInput } @@ -25,7 +27,7 @@ func (t *managerStubTool) Description() string { return "stub tool" } func (t *managerStubTool) Schema() map[string]any { return map[string]any{"type": "object"} } -func (t *managerStubTool) MicroCompactPolicy() MicroCompactPolicy { return MicroCompactPolicyCompact } +func (t *managerStubTool) MicroCompactPolicy() MicroCompactPolicy { return t.policy } func (t *managerStubTool) Execute(ctx context.Context, call ToolCallInput) (ToolResult, error) { t.callCount++ @@ -42,6 +44,21 @@ type stubSandbox struct { lastAction security.Action } +type executorWithoutMicroCompactPolicy struct{} + +func (executorWithoutMicroCompactPolicy) ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]provider.ToolSpec, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return nil, nil +} + +func (executorWithoutMicroCompactPolicy) Execute(ctx context.Context, call ToolCallInput) (ToolResult, error) { + return ToolResult{}, ctx.Err() +} + +func (executorWithoutMicroCompactPolicy) Supports(name string) bool { return false } + func (s *stubSandbox) Check(ctx context.Context, action security.Action) (*security.WorkspaceExecutionPlan, error) { s.callCount++ s.lastAction = action @@ -70,6 +87,46 @@ func TestDefaultManagerListAvailableSpecs(t *testing.T) { } } +func TestDefaultManagerMicroCompactPolicy(t *testing.T) { + t.Parallel() + + t.Run("nil manager defaults to compact", func(t *testing.T) { + t.Parallel() + + var manager *DefaultManager + if got := manager.MicroCompactPolicy("custom_tool"); got != MicroCompactPolicyCompact { + t.Fatalf("expected compact default, got %q", got) + } + }) + + t.Run("executor without policy support defaults to compact", func(t *testing.T) { + t.Parallel() + + manager, err := NewManager(executorWithoutMicroCompactPolicy{}, nil, nil) + if err != nil { + t.Fatalf("new manager: %v", err) + } + if got := manager.MicroCompactPolicy("custom_tool"); got != MicroCompactPolicyCompact { + t.Fatalf("expected compact default, got %q", got) + } + }) + + t.Run("executor policy is forwarded", func(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + registry.Register(&managerStubTool{name: "preserve_tool", policy: MicroCompactPolicyPreserveHistory}) + + manager, err := NewManager(registry, nil, nil) + if err != nil { + t.Fatalf("new manager: %v", err) + } + if got := manager.MicroCompactPolicy("preserve_tool"); got != MicroCompactPolicyPreserveHistory { + t.Fatalf("expected preserve history, got %q", got) + } + }) +} + func TestDefaultManagerListAvailableSpecsBoundaries(t *testing.T) { t.Parallel() From c25604438189fed694fdaddc4e3976b20447627b Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Mon, 6 Apr 2026 13:58:09 +0800 Subject: [PATCH 9/9] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=20micro=20compac?= =?UTF-8?q?t=20=E8=BE=B9=E7=95=8C=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/context/microcompact_test.go | 34 ++++++++++++++++ internal/tools/manager_test.go | 57 ++++++++++++++++++++++++++- internal/tools/registry_test.go | 21 ++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/internal/context/microcompact_test.go b/internal/context/microcompact_test.go index 22a95fa4..a690b4c6 100644 --- a/internal/context/microcompact_test.go +++ b/internal/context/microcompact_test.go @@ -64,6 +64,27 @@ func TestMicroCompactMessagesClearsOlderCompactableToolResults(t *testing.T) { } } +func TestMicroCompactMessagesHandlesEmptyAndInvalidSpanInputs(t *testing.T) { + t.Parallel() + + if got := microCompactMessages(nil); got != nil { + t.Fatalf("expected nil input to remain nil, got %+v", got) + } + + assistantOnly := []provider.Message{ + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "", Name: "bash", Arguments: "{}"}, + }, + }, + } + got := microCompactMessagesWithPolicies(assistantOnly, stubMicroCompactPolicySource{}) + if len(got) != 1 || len(got[0].ToolCalls) != 1 { + t.Fatalf("expected invalid tool call id path to keep message untouched, got %+v", got) + } +} + func TestMicroCompactMessagesKeepsProtectedTailUntouched(t *testing.T) { t.Parallel() @@ -312,3 +333,16 @@ func TestMicroCompactMessagesSkipsEmptyRecentSpansWhenCountingRetainedBudget(t * t.Fatalf("expected empty recent tool result to remain unchanged, got %q", got[10].Content) } } + +func TestMicroCompactMessagesSkipsToolMessagesWhenCompactableIDsMissing(t *testing.T) { + t.Parallel() + + messages := []provider.Message{ + {Role: provider.RoleTool, ToolCallID: "orphan", Content: "orphan result"}, + } + + got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}) + if got[0].Content != "orphan result" { + t.Fatalf("expected orphan tool result to remain, got %q", got[0].Content) + } +} diff --git a/internal/tools/manager_test.go b/internal/tools/manager_test.go index 9a1cac9e..7c51a7c1 100644 --- a/internal/tools/manager_test.go +++ b/internal/tools/manager_test.go @@ -40,6 +40,7 @@ func (t *managerStubTool) Execute(ctx context.Context, call ToolCallInput) (Tool type stubSandbox struct { err error + plan *security.WorkspaceExecutionPlan callCount int lastAction security.Action } @@ -65,7 +66,7 @@ func (s *stubSandbox) Check(ctx context.Context, action security.Action) (*secur if err := ctx.Err(); err != nil { return nil, err } - return nil, s.err + return s.plan, s.err } func TestDefaultManagerListAvailableSpecs(t *testing.T) { @@ -402,6 +403,43 @@ func TestDefaultManagerExecuteWithWorkspaceSandbox(t *testing.T) { } } +func TestDefaultManagerExecuteForwardsWorkspacePlanToTool(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + tool := &managerStubTool{name: "filesystem_write_file", content: "ok"} + registry.Register(tool) + + engine, err := security.NewStaticGateway(security.DecisionAllow, nil) + if err != nil { + t.Fatalf("new engine: %v", err) + } + plan := &security.WorkspaceExecutionPlan{ + Root: "workspace-root", + Target: "workspace-root/notes.txt", + RequestedTarget: "notes.txt", + } + manager, err := NewManager(registry, engine, &stubSandbox{plan: plan}) + if err != nil { + t.Fatalf("new manager: %v", err) + } + + result, execErr := manager.Execute(context.Background(), ToolCallInput{ + Name: "filesystem_write_file", + Arguments: []byte(`{"path":"notes.txt","content":"hello"}`), + Workdir: t.TempDir(), + }) + if execErr != nil { + t.Fatalf("unexpected error: %v", execErr) + } + if result.Content != "ok" { + t.Fatalf("expected ok result, got %+v", result) + } + if tool.lastCall.WorkspacePlan == nil || tool.lastCall.WorkspacePlan.Target != plan.Target { + t.Fatalf("expected workspace plan to be forwarded, got %+v", tool.lastCall.WorkspacePlan) + } +} + func TestPermissionDecisionError(t *testing.T) { t.Parallel() @@ -455,6 +493,23 @@ func TestPermissionDecisionError(t *testing.T) { if nilErr.Error() != "" || nilErr.Decision() != "" || nilErr.ToolName() != "" { t.Fatalf("expected nil permission error helpers to be empty") } + if nilErr.Reason() != "" || nilErr.RuleID() != "" || nilErr.Action() != (security.Action{}) { + t.Fatalf("expected nil permission error extended helpers to be empty") + } + + defaultAsk := &PermissionDecisionError{decision: security.DecisionAsk} + if !strings.Contains(defaultAsk.Error(), "permission approval required") { + t.Fatalf("expected default ask message, got %q", defaultAsk.Error()) + } +} + +func TestNewManagerRejectsNilExecutor(t *testing.T) { + t.Parallel() + + manager, err := NewManager(nil, nil, nil) + if err == nil || !strings.Contains(err.Error(), "executor is nil") { + t.Fatalf("expected nil executor error, got manager=%v err=%v", manager, err) + } } func TestBuildPermissionAction(t *testing.T) { diff --git a/internal/tools/registry_test.go b/internal/tools/registry_test.go index ec09852a..acf66f05 100644 --- a/internal/tools/registry_test.go +++ b/internal/tools/registry_test.go @@ -206,3 +206,24 @@ func TestRegistryMicroCompactPolicyPreserveHistory(t *testing.T) { t.Fatalf("expected preserve history policy, got %q", got) } } + +func TestRegistryMicroCompactPolicyNormalizesNameAndNilRegistry(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + registry.Register(stubTool{ + name: "Custom_Tool", + description: "preserve history", + schema: map[string]any{"type": "object"}, + policy: MicroCompactPolicyPreserveHistory, + }) + + if got := registry.MicroCompactPolicy(" custom_tool "); got != MicroCompactPolicyPreserveHistory { + t.Fatalf("expected normalized preserve history policy, got %q", got) + } + + var nilRegistry *Registry + if got := nilRegistry.MicroCompactPolicy("whatever"); got != MicroCompactPolicyCompact { + t.Fatalf("expected nil registry default compact policy, got %q", got) + } +}