From 311764a596406cbdfc47d73490098ccb8d132fde Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Tue, 14 Apr 2026 20:41:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(context):=20=E4=BF=AE=E5=A4=8D=E7=BB=AD?= =?UTF-8?q?=E8=B7=91=E4=B8=8A=E4=B8=8B=E6=96=87=E8=A3=81=E5=89=AA=E4=B8=8E?= =?UTF-8?q?=20loop=5Flimit=20=E6=A3=80=E6=9F=A5=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/context-compact.md | 13 +- docs/guides/configuration.md | 2 + docs/runtime-provider-event-flow.md | 6 + internal/config/config_test.go | 31 ++++ internal/config/context.go | 9 + internal/config/context_test.go | 6 + internal/config/loader.go | 3 + internal/context/builder.go | 2 +- internal/context/builder_test.go | 17 +- internal/context/compact/planner.go | 2 +- internal/context/compact/runner.go | 4 +- internal/context/trim.go | 9 +- internal/context/trim_policy.go | 6 +- internal/context/types.go | 1 + internal/runtime/run.go | 17 +- internal/runtime/runtime_test.go | 278 ++++++++++++++++++++++++++++ 16 files changed, 389 insertions(+), 17 deletions(-) diff --git a/docs/context-compact.md b/docs/context-compact.md index 20a3ed8e..986aa85a 100644 --- a/docs/context-compact.md +++ b/docs/context-compact.md @@ -4,8 +4,8 @@ ## 概览 -- runtime 已接入手动 compact、基于 token 阈值的自动 compact,以及 provider 上下文过长后的 `reactive` compact 自动恢复。 -- `internal/context/compact` 支持 `manual`、`auto` 与 `reactive` 三种 mode。 +- runtime 已接入手动 compact、基于 token 阈值的自动 compact、provider 上下文过长后的 `reactive` compact 自动恢复,以及命中最大轮数前的 `loop_limit` continuation checkpoint。 +- `internal/context/compact` 支持 `manual`、`auto`、`reactive` 与 `loop_limit` 四种 mode。 - 用户通过 `/compact` 对当前会话执行一次上下文压缩。 - compact 前会先写入完整 transcript,随后生成并校验新的 durable `TaskState` 与 display summary,再回写会话消息。 @@ -18,6 +18,7 @@ context: compact: manual_strategy: keep_recent manual_keep_recent_messages: 10 + read_time_max_message_spans: 24 max_summary_chars: 1200 micro_compact_disabled: false auto_compact: @@ -29,6 +30,8 @@ context: 控制手动 compact 的策略,支持 `keep_recent` 和 `full_replace`。 - `manual_keep_recent_messages` 在 `keep_recent` 模式下保留最近消息数量,并按 tool call 与 tool result 的原子块整体保留。 +- `read_time_max_message_spans` + 控制 `context.Builder` 读时 trim 可保留的 message span 上限;该值越大,普通“继续”续跑时越不容易在未触发 compact 前丢掉较早的文件读取结果。 - `max_summary_chars` 控制 compact summary 的最大字符数。 - `micro_compact_disabled` @@ -77,6 +80,12 @@ context: 3. 继续复用 `compact_start`、`compact_done`、`compact_error` 事件,并通过 `trigger_mode=reactive` 区分来源。 4. 每次 `Run()` 最多只执行一次 reactive 重试,避免无限循环。 +当 runtime 命中 `max_loops` 停止条件时,还会在返回最终错误前 best-effort 触发一次 `compact.Run(mode=loop_limit)` 作为 continuation checkpoint: + +1. 成功时回写 compact 后的消息、durable `TaskState` 与重置后的 token 计数。 +2. 继续复用 `compact_start`、`compact_done`、`compact_error` 事件,并通过 `trigger_mode=loop_limit` 标记来源。 +3. 无论 compact 成功、失败或 no-op,本次 run 仍会结束;区别只在于后续“继续”能否优先复用 durable checkpoint。 + ## 生成协议 compact generator 必须只返回一个 JSON 对象,顶层固定包含: diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index f7beab33..548f0661 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -52,6 +52,7 @@ context: compact: manual_strategy: keep_recent manual_keep_recent_messages: 10 + read_time_max_message_spans: 24 max_summary_chars: 1200 micro_compact_disabled: false auto_compact: @@ -75,6 +76,7 @@ context: |------|------| | `context.compact.manual_strategy` | `/compact` 手动压缩策略,支持 `keep_recent` / `full_replace` | | `context.compact.manual_keep_recent_messages` | `keep_recent` 策略下保留的最近消息数 | +| `context.compact.read_time_max_message_spans` | context 读时保留的 message span 上限,用于降低“继续”时较早文件读取结果被过早裁掉的风险 | | `context.compact.max_summary_chars` | compact summary 最大字符数 | | `context.compact.micro_compact_disabled` | 是否关闭默认启用的 micro compact | | `context.auto_compact.enabled` | 是否启用自动压缩 | diff --git a/docs/runtime-provider-event-flow.md b/docs/runtime-provider-event-flow.md index 247e5646..f8253f88 100644 --- a/docs/runtime-provider-event-flow.md +++ b/docs/runtime-provider-event-flow.md @@ -21,6 +21,8 @@ - `compact_done` - `compact_error` +这三类 compact 事件同时用于 `manual`、`auto`、`reactive` 与 `loop_limit` 四种来源,调用方可通过 payload 中的 `trigger_mode` 区分。 + ## ReAct 主循环 1. 加载目标会话或创建新会话。 @@ -36,6 +38,10 @@ 11. 如果最终 assistant 回复后没有后续工具调用,则在 runtime 收口处安排一次后台 memo 自动提取。 12. 如果仍需继续推理,则进入下一轮;否则结束。 +补充说明: +- 当下一轮开始前已命中 `max_loops` 上限时,runtime 会先尝试一次 `loop_limit` compact checkpoint,再发出最终 `error` 事件结束本次 run。 +- 该 checkpoint 成功时会先发出 `compact_start -> compact_done`,并把 session 消息、`TaskState` 与累计 token 重置后的状态持久化;失败时会发出 `compact_error`,随后仍以最大轮数错误结束。 + ### Memo 自动提取调度 - 自动提取只在最终 assistant 回复完成且当前轮没有后续工具调用时调度。 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 83c37cbf..12aa0394 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1082,6 +1082,13 @@ func TestCompactConfigDefaultsAndRoundTrip(t *testing.T) { if compactCfg.MaxSummaryChars != DefaultCompactMaxSummaryChars { t.Fatalf("expected max_summary_chars=%d, got %d", DefaultCompactMaxSummaryChars, compactCfg.MaxSummaryChars) } + if compactCfg.ReadTimeMaxMessageSpans != DefaultCompactReadTimeMaxMessageSpans { + t.Fatalf( + "expected read_time_max_message_spans=%d, got %d", + DefaultCompactReadTimeMaxMessageSpans, + compactCfg.ReadTimeMaxMessageSpans, + ) + } if compactCfg.MicroCompactDisabled { t.Fatalf("expected micro compact to be enabled by default") } @@ -1089,6 +1096,7 @@ func TestCompactConfigDefaultsAndRoundTrip(t *testing.T) { cfg.Context.Compact.ManualStrategy = CompactManualStrategyFullReplace cfg.Context.Compact.ManualKeepRecentMessages = 2 cfg.Context.Compact.MaxSummaryChars = 900 + cfg.Context.Compact.ReadTimeMaxMessageSpans = 30 cfg.Context.Compact.MicroCompactDisabled = true if err := loader.Save(context.Background(), cfg); err != nil { t.Fatalf("Save() error = %v", err) @@ -1107,6 +1115,9 @@ func TestCompactConfigDefaultsAndRoundTrip(t *testing.T) { if !strings.Contains(text, "micro_compact_disabled: true") { t.Fatalf("expected persisted config to include micro_compact_disabled, got:\n%s", text) } + if !strings.Contains(text, "read_time_max_message_spans: 30") { + t.Fatalf("expected persisted config to include read_time_max_message_spans, got:\n%s", text) + } reloaded, err := loader.Load(context.Background()) if err != nil { @@ -1121,6 +1132,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.ReadTimeMaxMessageSpans != 30 { + t.Fatalf("expected read_time_max_message_spans=30, got %d", reloaded.Context.Compact.ReadTimeMaxMessageSpans) + } if !reloaded.Context.Compact.MicroCompactDisabled { t.Fatalf("expected micro_compact_disabled to persist") } @@ -1138,6 +1152,7 @@ func TestCompactConfigValidateFailures(t *testing.T) { ManualStrategy: "invalid", ManualKeepRecentMessages: 10, MaxSummaryChars: 1200, + ReadTimeMaxMessageSpans: 24, }, expectErr: "manual_strategy", }, @@ -1147,6 +1162,7 @@ func TestCompactConfigValidateFailures(t *testing.T) { ManualStrategy: CompactManualStrategyKeepRecent, ManualKeepRecentMessages: 0, MaxSummaryChars: 1200, + ReadTimeMaxMessageSpans: 24, }, expectErr: "manual_keep_recent_messages", }, @@ -1156,9 +1172,20 @@ func TestCompactConfigValidateFailures(t *testing.T) { ManualStrategy: CompactManualStrategyKeepRecent, ManualKeepRecentMessages: 10, MaxSummaryChars: 0, + ReadTimeMaxMessageSpans: 24, }, expectErr: "max_summary_chars", }, + { + name: "invalid read time max spans", + compact: CompactConfig{ + ManualStrategy: CompactManualStrategyKeepRecent, + ManualKeepRecentMessages: 10, + MaxSummaryChars: 1200, + ReadTimeMaxMessageSpans: 0, + }, + expectErr: "read_time_max_message_spans", + }, } for _, tt := range tests { @@ -1177,6 +1204,7 @@ func TestCompactConfigValidateSupportsFullReplace(t *testing.T) { ManualStrategy: CompactManualStrategyFullReplace, ManualKeepRecentMessages: 10, MaxSummaryChars: 1200, + ReadTimeMaxMessageSpans: 24, }).Validate() if err != nil { t.Fatalf("expected full_replace strategy to validate, got %v", err) @@ -1261,6 +1289,7 @@ func TestContextConfigApplyDefaultsPropagatesAutoCompactDefaults(t *testing.T) { ManualStrategy: CompactManualStrategyKeepRecent, ManualKeepRecentMessages: 10, MaxSummaryChars: 1200, + ReadTimeMaxMessageSpans: 24, }, }) @@ -1350,6 +1379,7 @@ func TestAutoCompactConfigContextConfigValidate(t *testing.T) { ManualStrategy: CompactManualStrategyKeepRecent, ManualKeepRecentMessages: 10, MaxSummaryChars: 1200, + ReadTimeMaxMessageSpans: 24, }, } @@ -1560,6 +1590,7 @@ func TestValidateSnapshotPropagatesCompactError(t *testing.T) { ManualStrategy: "invalid_strategy", ManualKeepRecentMessages: 10, MaxSummaryChars: 1200, + ReadTimeMaxMessageSpans: 24, }, }, } diff --git a/internal/config/context.go b/internal/config/context.go index 0ee716e0..c0c2ffb5 100644 --- a/internal/config/context.go +++ b/internal/config/context.go @@ -11,6 +11,7 @@ const ( DefaultCompactMaxSummaryChars = 1200 DefaultAutoCompactInputTokenThreshold = 100000 DefaultMicroCompactRetainedToolSpans = 2 + DefaultCompactReadTimeMaxMessageSpans = 24 CompactManualStrategyKeepRecent = "keep_recent" CompactManualStrategyFullReplace = "full_replace" @@ -27,6 +28,7 @@ type CompactConfig struct { MaxSummaryChars int `yaml:"max_summary_chars,omitempty"` MicroCompactDisabled bool `yaml:"micro_compact_disabled,omitempty"` MicroCompactRetainedToolSpans int `yaml:"micro_compact_retained_tool_spans,omitempty"` + ReadTimeMaxMessageSpans int `yaml:"read_time_max_message_spans,omitempty"` MaxArchivedPromptChars int `yaml:"max_archived_prompt_chars,omitempty"` } @@ -57,6 +59,7 @@ func defaultCompactConfig() CompactConfig { ManualKeepRecentMessages: DefaultCompactManualKeepRecentMessages, MaxSummaryChars: DefaultCompactMaxSummaryChars, MicroCompactRetainedToolSpans: DefaultMicroCompactRetainedToolSpans, + ReadTimeMaxMessageSpans: DefaultCompactReadTimeMaxMessageSpans, } } @@ -106,6 +109,9 @@ func (c *CompactConfig) ApplyDefaults(defaults CompactConfig) { if c.MicroCompactRetainedToolSpans <= 0 { c.MicroCompactRetainedToolSpans = defaults.MicroCompactRetainedToolSpans } + if c.ReadTimeMaxMessageSpans <= 0 { + c.ReadTimeMaxMessageSpans = defaults.ReadTimeMaxMessageSpans + } } // ApplyDefaults 为 auto_compact 配置填充缺省阈值。 @@ -137,6 +143,9 @@ func (c CompactConfig) Validate() error { if c.MaxSummaryChars <= 0 { return errors.New("max_summary_chars must be greater than 0") } + if c.ReadTimeMaxMessageSpans <= 0 { + return errors.New("read_time_max_message_spans must be greater than 0") + } switch strings.ToLower(strings.TrimSpace(c.ManualStrategy)) { case CompactManualStrategyKeepRecent, CompactManualStrategyFullReplace: diff --git a/internal/config/context_test.go b/internal/config/context_test.go index 909d42f7..8dd9186e 100644 --- a/internal/config/context_test.go +++ b/internal/config/context_test.go @@ -13,6 +13,7 @@ func TestContextConfigCloneIndependence(t *testing.T) { ManualStrategy: CompactManualStrategyKeepRecent, ManualKeepRecentMessages: 10, MaxSummaryChars: 1200, + ReadTimeMaxMessageSpans: 24, }, AutoCompact: AutoCompactConfig{ Enabled: true, @@ -48,6 +49,7 @@ func TestCompactConfigCloneValueSemantics(t *testing.T) { ManualKeepRecentMessages: 5, MaxSummaryChars: 800, MicroCompactDisabled: true, + ReadTimeMaxMessageSpans: 24, } cloned := original.Clone() if original != cloned { @@ -98,6 +100,7 @@ func TestCompactConfigApplyDefaultsAllZeroValues(t *testing.T) { ManualStrategy: CompactManualStrategyFullReplace, ManualKeepRecentMessages: 15, MaxSummaryChars: 2000, + ReadTimeMaxMessageSpans: 24, } cfg.ApplyDefaults(defaults) if cfg.ManualStrategy != CompactManualStrategyFullReplace { @@ -109,4 +112,7 @@ func TestCompactConfigApplyDefaultsAllZeroValues(t *testing.T) { if cfg.MaxSummaryChars != 2000 { t.Fatalf("expected chars=2000, got %d", cfg.MaxSummaryChars) } + if cfg.ReadTimeMaxMessageSpans != 24 { + t.Fatalf("expected read-time spans=24, got %d", cfg.ReadTimeMaxMessageSpans) + } } diff --git a/internal/config/loader.go b/internal/config/loader.go index 14cf26cb..d0129ab5 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -43,6 +43,7 @@ type persistedCompactConfig struct { MaxSummaryChars int `yaml:"max_summary_chars,omitempty"` MicroCompactDisabled bool `yaml:"micro_compact_disabled,omitempty"` MicroCompactRetainedToolSpans int `yaml:"micro_compact_retained_tool_spans,omitempty"` + ReadTimeMaxMessageSpans int `yaml:"read_time_max_message_spans,omitempty"` MaxArchivedPromptChars int `yaml:"max_archived_prompt_chars,omitempty"` } @@ -244,6 +245,7 @@ func newPersistedContextConfig(cfg ContextConfig) persistedContextConfig { MaxSummaryChars: cfg.Compact.MaxSummaryChars, MicroCompactDisabled: cfg.Compact.MicroCompactDisabled, MicroCompactRetainedToolSpans: cfg.Compact.MicroCompactRetainedToolSpans, + ReadTimeMaxMessageSpans: cfg.Compact.ReadTimeMaxMessageSpans, MaxArchivedPromptChars: cfg.Compact.MaxArchivedPromptChars, }, AutoCompact: persistedAutoCompactConfig{ @@ -262,6 +264,7 @@ func fromPersistedContextConfig(file persistedContextConfig, defaults ContextCon MaxSummaryChars: file.Compact.MaxSummaryChars, MicroCompactDisabled: file.Compact.MicroCompactDisabled, MicroCompactRetainedToolSpans: file.Compact.MicroCompactRetainedToolSpans, + ReadTimeMaxMessageSpans: file.Compact.ReadTimeMaxMessageSpans, MaxArchivedPromptChars: file.Compact.MaxArchivedPromptChars, }, AutoCompact: AutoCompactConfig{ diff --git a/internal/context/builder.go b/internal/context/builder.go index 7f71caae..41eb9486 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -79,7 +79,7 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu return BuildResult{ SystemPrompt: composeSystemPrompt(sections...), - Messages: applyReadTimeContextProjection(trimPolicy.Trim(input.Messages), input.TaskState, input.Compact, b.microCompactPolicies), + Messages: applyReadTimeContextProjection(trimPolicy.Trim(input.Messages, input.Compact), input.TaskState, input.Compact, b.microCompactPolicies), AutoCompactSuggested: shouldAutoCompact, }, nil } diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index 98944de2..efe32e55 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -9,12 +9,15 @@ import ( "strings" "testing" + "neo-code/internal/config" "neo-code/internal/context/internalcompact" providertypes "neo-code/internal/provider/types" agentsession "neo-code/internal/session" "neo-code/internal/tools" ) +const maxRetainedMessageSpans = config.DefaultCompactReadTimeMaxMessageSpans + type stubPromptSectionSource struct { sections []promptSection err error @@ -440,7 +443,7 @@ func TestTrimMessagesPreservesToolPairs(t *testing.T) { providertypes.Message{Role: "user", Content: "latest"}, ) - trimmed := trimMessages(messages) + trimmed := trimMessages(messages, maxRetainedMessageSpans) if len(trimmed) > len(messages) { t.Fatalf("trimmed messages should not grow") } @@ -463,6 +466,8 @@ func TestTrimMessagesPreservesToolPairs(t *testing.T) { func TestTrimMessagesProtectsLatestExplicitUserInstructionTail(t *testing.T) { t.Parallel() + const retainedSpans = 10 + messages := make([]providertypes.Message, 0, maxRetainedMessageSpans+5) for i := 0; i < 2; i++ { messages = append(messages, providertypes.Message{Role: providertypes.RoleUser, Content: fmt.Sprintf("old-%d", i)}) @@ -482,7 +487,7 @@ func TestTrimMessagesProtectsLatestExplicitUserInstructionTail(t *testing.T) { providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-11"}, ) - trimmed := trimMessages(messages) + trimmed := trimMessages(messages, retainedSpans) if trimmed[0].Role != providertypes.RoleUser || trimmed[0].Content != "latest explicit instruction" { t.Fatalf("expected protected tail to keep latest explicit user instruction, got %+v", trimmed[0]) } @@ -494,6 +499,8 @@ func TestTrimMessagesProtectsLatestExplicitUserInstructionTail(t *testing.T) { func TestTrimMessagesUsesSharedSpanModel(t *testing.T) { t.Parallel() + const retainedSpans = 10 + messages := make([]providertypes.Message, 0, maxRetainedMessageSpans+6) for i := 0; i < 3; i++ { messages = append(messages, providertypes.Message{Role: providertypes.RoleUser, Content: fmt.Sprintf("u-%d", i)}) @@ -518,9 +525,9 @@ func TestTrimMessagesUsesSharedSpanModel(t *testing.T) { ) spans := internalcompact.BuildMessageSpans(messages) - trimmed := trimMessages(messages) + trimmed := trimMessages(messages, retainedSpans) - start := spans[len(spans)-maxRetainedMessageSpans].Start + start := spans[len(spans)-retainedSpans].Start if len(trimmed) == 0 || trimmed[0].Content != messages[start].Content { t.Fatalf("expected trim to start from shared span boundary %d, got %+v", start, trimmed) } @@ -613,7 +620,7 @@ func TestTrimMessagesBoundaries(t *testing.T) { for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - trimmed := trimMessages(tt.input) + trimmed := trimMessages(tt.input, maxRetainedMessageSpans) if len(trimmed) != tt.wantLen { t.Fatalf("expected len %d, got %d", tt.wantLen, len(trimmed)) } diff --git a/internal/context/compact/planner.go b/internal/context/compact/planner.go index 7ecb2204..dc83a6ff 100644 --- a/internal/context/compact/planner.go +++ b/internal/context/compact/planner.go @@ -22,7 +22,7 @@ type compactionPlanner struct{} // Plan 根据 mode 与配置返回摘要前的裁剪规划结果。 func (compactionPlanner) Plan(mode Mode, messages []providertypes.Message, cfg config.CompactConfig) (compactionPlan, error) { - if mode == ModeReactive { + if mode == ModeReactive || mode == ModeLoopLimit { return planKeepRecent(messages, cfg.ManualKeepRecentMessages), nil } diff --git a/internal/context/compact/runner.go b/internal/context/compact/runner.go index b093a266..0dd39200 100644 --- a/internal/context/compact/runner.go +++ b/internal/context/compact/runner.go @@ -23,6 +23,8 @@ const ( ModeAuto Mode = "auto" // ModeReactive runs the provider-error-triggered compact flow. ModeReactive Mode = "reactive" + // ModeLoopLimit runs the continuation checkpoint flow triggered before loop-budget exit. + ModeLoopLimit Mode = "loop_limit" ) // ErrorMode classifies compact result errors. @@ -127,7 +129,7 @@ func (s *Service) Run(ctx context.Context, input Input) (Result, error) { return Result{}, err } - if input.Mode != ModeManual && input.Mode != ModeAuto && input.Mode != ModeReactive { + if input.Mode != ModeManual && input.Mode != ModeAuto && input.Mode != ModeReactive && input.Mode != ModeLoopLimit { return Result{}, fmt.Errorf("compact: unsupported mode %q", input.Mode) } diff --git a/internal/context/trim.go b/internal/context/trim.go index d818a6fe..a14c24d4 100644 --- a/internal/context/trim.go +++ b/internal/context/trim.go @@ -1,14 +1,17 @@ package context import ( + "neo-code/internal/config" "neo-code/internal/context/internalcompact" providertypes "neo-code/internal/provider/types" ) -const maxRetainedMessageSpans = 10 +// trimMessages 按消息分段裁剪上下文,并始终保护最后一条明确用户指令所在尾部。 +func trimMessages(messages []providertypes.Message, maxRetainedMessageSpans int) []providertypes.Message { + if maxRetainedMessageSpans <= 0 { + maxRetainedMessageSpans = config.DefaultCompactReadTimeMaxMessageSpans + } -// trimMessages 按消息分段裁剪上下文,并始终保护最近一条明确用户指令所在尾部。 -func trimMessages(messages []providertypes.Message) []providertypes.Message { spans := internalcompact.BuildMessageSpans(messages) if len(spans) <= maxRetainedMessageSpans { return append([]providertypes.Message(nil), messages...) diff --git a/internal/context/trim_policy.go b/internal/context/trim_policy.go index 6e7c73b1..3ca14981 100644 --- a/internal/context/trim_policy.go +++ b/internal/context/trim_policy.go @@ -6,13 +6,13 @@ import ( // messageTrimPolicy 约束消息裁剪策略的最小接口,避免 Builder 直接持有裁剪细节。 type messageTrimPolicy interface { - Trim(messages []providertypes.Message) []providertypes.Message + Trim(messages []providertypes.Message, options CompactOptions) []providertypes.Message } // spanMessageTrimPolicy 以消息 span 为单位裁剪历史,确保 tool block 不被拆散。 type spanMessageTrimPolicy struct{} // Trim 返回保留关键 tool block 原子性的裁剪后消息副本。 -func (spanMessageTrimPolicy) Trim(messages []providertypes.Message) []providertypes.Message { - return trimMessages(messages) +func (spanMessageTrimPolicy) Trim(messages []providertypes.Message, options CompactOptions) []providertypes.Message { + return trimMessages(messages, options.ReadTimeMaxMessageSpans) } diff --git a/internal/context/types.go b/internal/context/types.go index 2740eb00..2a6b8884 100644 --- a/internal/context/types.go +++ b/internal/context/types.go @@ -38,4 +38,5 @@ type CompactOptions struct { DisableMicroCompact bool AutoCompactThreshold int MicroCompactRetainedToolSpans int + ReadTimeMaxMessageSpans int } diff --git a/internal/runtime/run.go b/internal/runtime/run.go index b0caf20a..9b33fcf1 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -71,7 +71,21 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { for turn := 0; ; turn++ { maxLoops := resolveMaxLoops(s.configManager.Get()) if turn >= maxLoops { - err := errors.New("runtime: max loop reached") + errMessage := "runtime: max loop reached" + applied, compactErr := s.applyCompactForState( + ctx, + &state, + s.configManager.Get(), + contextcompact.ModeLoopLimit, + compactErrorBestEffort, + ) + if compactErr != nil { + return s.handleRunError(ctx, state.runID, state.session.ID, compactErr) + } + if applied { + errMessage = "runtime: max loop reached after saving continuation checkpoint" + } + err := errors.New(errMessage) s.emit(ctx, EventError, state.runID, state.session.ID, err.Error()) return err } @@ -146,6 +160,7 @@ func (s *Service) prepareTurnSnapshot(ctx context.Context, state *runState) (tur DisableMicroCompact: cfg.Context.Compact.MicroCompactDisabled, AutoCompactThreshold: autoCompactThreshold(cfg), MicroCompactRetainedToolSpans: cfg.Context.Compact.MicroCompactRetainedToolSpans, + ReadTimeMaxMessageSpans: cfg.Context.Compact.ReadTimeMaxMessageSpans, }, }) if err != nil { diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 20cde84e..38826ce8 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -1867,6 +1867,275 @@ func TestServiceRunErrorPaths(t *testing.T) { } } +func TestServiceRunMaxLoopsSavesLoopLimitCheckpoint(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.MaxLoops = 1 + return nil + }); err != nil { + t.Fatalf("update config: %v", err) + } + store := newMemoryStore() + session := agentsession.New("loop-limit-success") + session.ID = "session-loop-limit-success" + session.TokenInputTotal = 33 + session.TokenOutputTotal = 7 + session.Messages = []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older request"}, + } + store.sessions[session.ID] = cloneSession(session) + + registry := tools.NewRegistry() + tool := &stubTool{name: "filesystem_edit", content: "loop tool output"} + registry.Register(tool) + + scripted := &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + ToolCalls: []providertypes.ToolCall{ + {ID: "loop-call", Name: "filesystem_edit", Arguments: `{"path":"x"}`}, + }, + }, + FinishReason: "tool_calls", + }, + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) + compactRunner := &stubCompactRunner{ + result: contextcompact.Result{ + Messages: []providertypes.Message{ + {Role: providertypes.RoleAssistant, Content: "[compact_summary]\ndone:\n- checkpoint saved\n\nin_progress:\n- continue"}, + {Role: providertypes.RoleUser, Content: "continue from saved checkpoint"}, + }, + TaskState: agentsession.TaskState{ + Goal: "Preserve continuation checkpoint", + Progress: []string{"Saved loop-limit compact checkpoint"}, + NextStep: "Resume analysis from compacted context", + OpenItems: []string{"Continue source inspection"}, + Decisions: []string{"Use loop_limit compact before max loop exit"}, + Blockers: []string{}, + KeyArtifacts: []string{ + "checkpoint-summary", + }, + UserConstraints: []string{"Prefer durable checkpoint over raw message tail"}, + }, + Applied: true, + Metrics: contextcompact.Metrics{ + BeforeChars: 120, + AfterChars: 48, + SavedRatio: 0.6, + TriggerMode: string(contextcompact.ModeLoopLimit), + }, + TranscriptID: "transcript_loop_limit", + TranscriptPath: "/tmp/loop_limit.jsonl", + }, + } + service.compactRunner = compactRunner + + err := service.Run(context.Background(), UserInput{ + SessionID: session.ID, + RunID: "run-loop-limit-success", + Content: "continue", + }) + if err == nil || !strings.Contains(err.Error(), "max loop reached after saving continuation checkpoint") { + t.Fatalf("expected loop-limit checkpoint error, got %v", err) + } + + if len(compactRunner.calls) != 1 { + t.Fatalf("expected loop-limit compact to run once, got %d", len(compactRunner.calls)) + } + if compactRunner.calls[0].Mode != contextcompact.ModeLoopLimit { + t.Fatalf("expected compact mode %q, got %q", contextcompact.ModeLoopLimit, compactRunner.calls[0].Mode) + } + if compactRunner.calls[0].SessionInputTokens != 33 { + t.Fatalf("expected pre-checkpoint input tokens to be preserved, got %d", compactRunner.calls[0].SessionInputTokens) + } + + saved, err := store.Load(context.Background(), session.ID) + if err != nil { + t.Fatalf("load compacted session: %v", err) + } + if len(saved.Messages) != 2 || !strings.Contains(saved.Messages[0].Content, "checkpoint saved") { + t.Fatalf("expected compacted checkpoint messages, got %+v", saved.Messages) + } + if saved.TaskState.Goal != "Preserve continuation checkpoint" { + t.Fatalf("expected persisted task state, got %+v", saved.TaskState) + } + if saved.TokenInputTotal != 0 || saved.TokenOutputTotal != 0 { + t.Fatalf("expected token totals reset after loop-limit checkpoint, got input=%d output=%d", saved.TokenInputTotal, saved.TokenOutputTotal) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{ + EventUserMessage, + EventToolStart, + EventToolChunk, + EventToolResult, + EventCompactStart, + EventCompactDone, + EventError, + }) + assertNoEventType(t, events, EventCompactError) + + compactStartIndex := eventIndex(events, EventCompactStart) + compactDoneIndex := eventIndex(events, EventCompactDone) + errorIndex := eventIndex(events, EventError) + if compactStartIndex == -1 || compactDoneIndex == -1 || errorIndex == -1 { + t.Fatalf("expected loop-limit events in %+v", events) + } + if !(compactStartIndex < compactDoneIndex && compactDoneIndex < errorIndex) { + t.Fatalf("expected compact_start -> compact_done -> error ordering, got %+v", events) + } + + foundLoopLimitDone := false + foundLoopLimitError := false + for _, event := range events { + switch event.Type { + case EventCompactDone: + payload, ok := event.Payload.(CompactResult) + if !ok { + t.Fatalf("expected CompactResult payload, got %T", event.Payload) + } + if payload.TriggerMode == string(contextcompact.ModeLoopLimit) { + foundLoopLimitDone = true + } + case EventError: + message, ok := event.Payload.(string) + if !ok { + t.Fatalf("expected string error payload, got %T", event.Payload) + } + if strings.Contains(message, "after saving continuation checkpoint") { + foundLoopLimitError = true + } + } + } + if !foundLoopLimitDone { + t.Fatalf("expected loop_limit compact_done payload in %+v", events) + } + if !foundLoopLimitError { + t.Fatalf("expected saved-checkpoint error payload in %+v", events) + } +} + +func TestServiceRunMaxLoopsLoopLimitCompactFailureFallsBackToOriginalExit(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.MaxLoops = 1 + return nil + }); err != nil { + t.Fatalf("update config: %v", err) + } + store := newMemoryStore() + session := agentsession.New("loop-limit-failure") + session.ID = "session-loop-limit-failure" + session.TokenInputTotal = 12 + session.TokenOutputTotal = 4 + session.Messages = []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older request"}, + } + store.sessions[session.ID] = cloneSession(session) + + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_edit", content: "loop tool output"}) + + scripted := &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + ToolCalls: []providertypes.ToolCall{ + {ID: "loop-call", Name: "filesystem_edit", Arguments: `{"path":"x"}`}, + }, + }, + FinishReason: "tool_calls", + }, + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) + compactRunner := &stubCompactRunner{err: errors.New("loop limit compact failed")} + service.compactRunner = compactRunner + + err := service.Run(context.Background(), UserInput{ + SessionID: session.ID, + RunID: "run-loop-limit-failure", + Content: "continue", + }) + if err == nil || !strings.Contains(err.Error(), "max loop reached") || strings.Contains(err.Error(), "after saving continuation checkpoint") { + t.Fatalf("expected original max loop error, got %v", err) + } + + if len(compactRunner.calls) != 1 { + t.Fatalf("expected loop-limit compact to be attempted once, got %d", len(compactRunner.calls)) + } + if compactRunner.calls[0].Mode != contextcompact.ModeLoopLimit { + t.Fatalf("expected compact mode %q, got %q", contextcompact.ModeLoopLimit, compactRunner.calls[0].Mode) + } + + saved, err := store.Load(context.Background(), session.ID) + if err != nil { + t.Fatalf("load session after failed checkpoint: %v", err) + } + if len(saved.Messages) != 4 { + t.Fatalf("expected original messages to remain after compact failure, got %+v", saved.Messages) + } + if saved.TokenInputTotal != 12 || saved.TokenOutputTotal != 4 { + t.Fatalf("expected token totals to remain unchanged, got input=%d output=%d", saved.TokenInputTotal, saved.TokenOutputTotal) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{ + EventUserMessage, + EventToolStart, + EventToolChunk, + EventToolResult, + EventCompactStart, + EventCompactError, + EventError, + }) + assertNoEventType(t, events, EventCompactDone) + + compactStartIndex := eventIndex(events, EventCompactStart) + compactErrorIndex := eventIndex(events, EventCompactError) + errorIndex := eventIndex(events, EventError) + if compactStartIndex == -1 || compactErrorIndex == -1 || errorIndex == -1 { + t.Fatalf("expected loop-limit failure events in %+v", events) + } + if !(compactStartIndex < compactErrorIndex && compactErrorIndex < errorIndex) { + t.Fatalf("expected compact_start -> compact_error -> error ordering, got %+v", events) + } + + foundLoopLimitCompactError := false + for _, event := range events { + switch event.Type { + case EventCompactError: + payload, ok := event.Payload.(CompactErrorPayload) + if !ok { + t.Fatalf("expected CompactErrorPayload, got %T", event.Payload) + } + if payload.TriggerMode == string(contextcompact.ModeLoopLimit) && strings.Contains(payload.Message, "loop limit compact failed") { + foundLoopLimitCompactError = true + } + case EventError: + message, ok := event.Payload.(string) + if !ok { + t.Fatalf("expected string error payload, got %T", event.Payload) + } + if strings.Contains(message, "after saving continuation checkpoint") { + t.Fatalf("did not expect saved-checkpoint message after compact failure: %+v", events) + } + } + } + if !foundLoopLimitCompactError { + t.Fatalf("expected loop_limit compact_error payload in %+v", events) + } +} + func TestServiceCancelActiveRun(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() @@ -2930,6 +3199,15 @@ func collectRuntimeEvents(events <-chan RuntimeEvent) []RuntimeEvent { } } +func eventIndex(events []RuntimeEvent, want EventType) int { + for index, event := range events { + if event.Type == want { + return index + } + } + return -1 +} + func assertEventSequence(t *testing.T, events []RuntimeEvent, expected []EventType) { t.Helper() for _, eventType := range expected {