From 321dc82647893ef1a2efd2b7ab2204b1914e0337 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Mon, 1 Jun 2026 04:42:38 -0400 Subject: [PATCH] =?UTF-8?q?feat(microcampact):=E5=88=A0=E9=99=A4microcompa?= =?UTF-8?q?ct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture/architecture-v1.md | 14 +- docs/architecture/architecture-v3.md | 5 +- docs/context-compact.md | 6 - docs/guides/configuration.md | 4 - docs/roadmap.md | 1 - docs/runtime-provider-event-flow.md | 2 +- docs/tech-debt.md | 1 - internal/app/bootstrap.go | 11 +- internal/app/bootstrap_test.go | 3 - internal/config/config_test.go | 10 - internal/config/context.go | 17 +- internal/config/loader.go | 36 +- internal/config/loader_test.go | 11 - internal/context/builder.go | 79 +-- internal/context/builder_test.go | 542 +----------------- internal/context/microcompact.go | 331 ----------- .../context/microcompact_summarizer_test.go | 416 -------------- internal/context/microcompact_test.go | 523 ----------------- internal/context/pin_checker.go | 92 --- internal/context/pin_checker_test.go | 126 ---- internal/context/projection.go | 29 +- internal/context/projection_test.go | 16 +- internal/context/types.go | 26 +- internal/runner/runner_test.go | 8 - internal/runtime/run.go | 2 - internal/runtime/runtime.go | 8 +- .../runtime_remaining_branches_test.go | 6 - internal/runtime/runtime_test.go | 182 ------ internal/tools/ask_user_tool.go | 4 - internal/tools/ask_user_tool_test.go | 3 - internal/tools/bash/tool.go | 7 +- internal/tools/codebase/read.go | 6 +- internal/tools/codebase/read_test.go | 3 - internal/tools/codebase/searchsymbol.go | 6 +- internal/tools/codebase/searchsymbol_test.go | 3 - internal/tools/codebase/searchtext.go | 6 +- internal/tools/codebase/searchtext_test.go | 3 - internal/tools/diagnose/tool.go | 7 +- internal/tools/diagnose/tool_test.go | 3 - internal/tools/filesystem/delete_file.go | 6 +- internal/tools/filesystem/edit.go | 7 +- internal/tools/filesystem/glob.go | 7 +- internal/tools/filesystem/grep.go | 7 +- internal/tools/filesystem/read_file.go | 7 +- .../tools/filesystem/tool_metadata_test.go | 8 +- internal/tools/filesystem/write_file.go | 7 +- internal/tools/manager.go | 38 -- internal/tools/manager_test.go | 99 ---- internal/tools/memo/list.go | 7 +- internal/tools/memo/list_test.go | 3 - internal/tools/memo/recall.go | 7 +- internal/tools/memo/recall_test.go | 3 - internal/tools/memo/remember.go | 7 +- internal/tools/memo/remove.go | 7 +- internal/tools/memo/remove_test.go | 3 - internal/tools/micro_compact_policy.go | 11 - internal/tools/micro_compact_summarizer.go | 6 - .../tools/micro_compact_summarizer_test.go | 460 --------------- .../micro_compact_summarizers_builtin.go | 282 --------- internal/tools/registry.go | 53 +- internal/tools/registry_test.go | 45 -- internal/tools/spawnsubagent/tool.go | 7 +- internal/tools/spawnsubagent/tool_test.go | 3 - internal/tools/todo/write.go | 7 +- internal/tools/todo/write_test.go | 3 - internal/tools/types.go | 1 - internal/tools/webfetch/tool.go | 7 +- www/reference/index.md | 2 +- 68 files changed, 102 insertions(+), 3566 deletions(-) delete mode 100644 internal/context/microcompact.go delete mode 100644 internal/context/microcompact_summarizer_test.go delete mode 100644 internal/context/microcompact_test.go delete mode 100644 internal/context/pin_checker.go delete mode 100644 internal/context/pin_checker_test.go delete mode 100644 internal/tools/micro_compact_policy.go delete mode 100644 internal/tools/micro_compact_summarizer.go delete mode 100644 internal/tools/micro_compact_summarizer_test.go delete mode 100644 internal/tools/micro_compact_summarizers_builtin.go diff --git a/docs/architecture/architecture-v1.md b/docs/architecture/architecture-v1.md index 24f442ee5..857f9efe9 100644 --- a/docs/architecture/architecture-v1.md +++ b/docs/architecture/architecture-v1.md @@ -524,7 +524,7 @@ graph LR | **模型适配器** | 归一化不同厂商的 Chat API 为统一的 `Generate()` + `EstimateInputTokens()` 接口;将厂商特定的流式响应格式转换为标准 `StreamEvent` | 厂商差异不泄漏到 Runtime;每个 Adapter 独立测试 | Provider | | **工具执行器** | 暴露工具的 Schema 供模型选择;校验参数并执行工具调用;在每次执行前经过安全守卫的权限裁决 | 所有模型可调用的能力收敛于此角色;不在 Runtime 或客户端中绕过 | Tools (Manager) | | **安全守卫** | 基于策略规则(Priority 排序)裁决每个操作的 allow/deny/ask 决策;校验工作区边界(路径穿越检测、Symlink 解析);管理会话级权限记忆 | 位于工具执行的关键路径上,不可跳过 | Security Engine | -| **上下文构建器** | 按会话状态 + 预算阈值动态组装 System Prompt 和消息列表;执行上下文压缩(MicroCompact / Full Compact / Trim) | 压缩时不丢失 System Prompt 和 Pin 标记的关键消息;组装顺序稳定 | Context | +| **上下文构建器** | 按会话状态 + 预算阈值动态组装 System Prompt 和消息列表;执行上下文压缩(Full Compact / Trim) | 压缩时不丢失 System Prompt;组装顺序稳定 | Context | | **状态管理者** | 持久化会话消息历史(SQLite);管理 Checkpoint 快照的创建/恢复/修剪;执行过期会话的自动清理 | 同会话并发写串行化(sessionLock);消息追加原子化 | Session | | **技能注入器** | 从文件系统扫描 SKILL.md;管理会话级 Skill 激活状态;按激活列表将 Skill Prompt 注入 System Prompt 的技能段落 | project 层覆盖 global 层(同名去重);单文件大小限制 1MB | Skills | | **远程执行代理** | 在远程/本机独立进程中接收 Gateway 的工具执行请求;校验 Capability Token;在本地完成工具执行并返回结果 | 主动连接 Gateway(反向连接);不开放入站端口;受 WorkdirAllowlist 限制 | Runner | @@ -718,7 +718,7 @@ D4(工具执行可控)要求 AI 的写操作可回滚。Checkpoint 在每次 ### 8.6 Context(Prompt 构建与上下文压缩) -**存在理由:** "AI 看到了什么"是一个独立于"AI 怎么推理"的架构关注点。将 Context 从 Runtime 中分离出来,意味着 Compact 策略(MicroCompact / Full Compact / Trim)可以独立演进,不需要修改推理循环。 +**存在理由:** "AI 看到了什么"是一个独立于"AI 怎么推理"的架构关注点。将 Context 从 Runtime 中分离出来,意味着 Compact 策略(Full Compact / Trim)可以独立演进,不需要修改推理循环。 **拥有的决策权:** System Prompt 的组装顺序(`corePrompt → capabilities → rules → taskState → planModeContext → todos → skillPrompt → repositoryContext → systemState` 的固定顺序);Compact 何时触发、采用什么级别(Micro vs Full vs Trim);哪些消息不能被压缩(Pin 标记)。 @@ -827,7 +827,7 @@ sequenceDiagram **触发条件:** 每轮推理前 `prepareTurnBudgetSnapshot` 检测到 Token 消耗接近预算阈值(基于 Provider 的 `EstimateInputTokens` 估算 + 配置的 `compact_trigger_ratio`)。 -**参与组件:** Runtime → Context Builder → MicroCompact → Compact Runner (Provider) → Session Store +**参与组件:** Runtime → Context Builder → Compact Runner (Provider) → Session Store **流程:** @@ -841,8 +841,6 @@ sequenceDiagram CC-->>RT: needsCompact = true RT->>CC: Compact(input) - CC->>CC: MicroCompact: 对可压缩 tool_result 摘要化 - alt MicroCompact 不足 CC->>CC: Full Compact: CompactRunner.Generate() 生成结构化摘要 end CC->>CC: Trim: 裁剪最旧消息(保留 System Prompt + Pin 标记) @@ -855,8 +853,7 @@ sequenceDiagram | 级别 | 触发条件 | 操作 | 对上下文的影响 | |------|----------|------|---------------| -| **MicroCompact** | 单次 Tool Call 结果过大,导致本轮预算紧张 | 对单个 tool_result 内容摘要化(保留关键输出,丢弃冗长中间日志) | 仅影响当前工具结果,不改变历史 | -| **Full Compact** | MicroCompact 后仍超预算,或累计历史消息过多 | 将历史消息中可压缩的部分通过 LLM 生成结构化摘要,替换原始消息 | 历史消息被摘要替代,System Prompt + 最近 N 轮保留 | +| **Full Compact** | 累计历史消息过多 | 将历史消息中可压缩的部分通过 LLM 生成结构化摘要,替换原始消息 | 历史消息被摘要替代,System Prompt + 最近 N 轮保留 | **关键不变量:** - System Prompt(corePrompt + capabilities + rules + skillPrompt)永不参与压缩 @@ -1065,7 +1062,7 @@ sequenceDiagram opt 若 Token 预算接近阈值 RT->>CTX: Compact(session) - CTX->>CTX: MicroCompact(tool_results) → FullCompact(history) + CTX->>CTX: FullCompact(history) CTX->>SS: ReplaceTranscript() end @@ -1773,7 +1770,6 @@ SessionID(会话级) + RunID(单次运行级) |------|------| | **ReAct Loop** | Reasoning + Acting 循环:模型推理 → 解析工具调用 → 执行工具 → 回灌结果 → 继续推理,直到产出最终文本回复 | | **Compact** | 上下文压缩:当对话历史累积到接近 Token 预算上限时,自动将历史消息摘要化或裁剪,以释放上下文空间 | -| **MicroCompact** | 轻量级压缩:仅对单个 tool_result 内容做摘要化,不改变消息列表结构。是 Compact 的第一阶段 | | **StreamRelay** | 流式中继:Gateway 内部将 Runtime 的异步事件按 SessionID/RunID 广播到所有订阅客户端连接的 pub/sub 机制 | | **Checkpoint** | 代码版本快照:AI 执行写操作前自动创建的文件状态快照,支持恢复和 Diff 查看 | | **Human-in-the-loop** | 人机协作模式:AI 在执行可能危险的操作(如写文件、执行 Bash)前暂停,等待人类审批 | diff --git a/docs/architecture/architecture-v3.md b/docs/architecture/architecture-v3.md index f46941cd8..90ef79078 100644 --- a/docs/architecture/architecture-v3.md +++ b/docs/architecture/architecture-v3.md @@ -64,7 +64,7 @@ graph TD |------|----------|----------| | **LLM 输出不稳定** | 同样的 Prompt,不同模型(甚至同一模型的不同请求)可能产出完全不同的工具调用策略和代码质量 | Provider 归一化 + Compact 保持上下文一致性 | | **工具执行具有副作用** | 模型决定执行 `rm -rf` 或修改关键配置文件,后果不可撤销 | Security Engine 四层防御 + Checkpoint 自动快照 | -| **上下文窗口有限** | 模型的 context window 有硬上限(4K–200K tokens),长对话和大代码库必然超限 | Context 模块两级 Compact 策略(MicroCompact / Full Compact) | +| **上下文窗口有限** | 模型的 context window 有硬上限(4K–200K tokens),长对话和大代码库必然超限 | Context 模块 Compact 策略(Full Compact / Trim) | | **多轮任务需要状态管理** | 一次任务可能跨越数十轮推理,中间包含工具调用、审批暂停、错误重试,状态必须一致 | Session 持久化 + Runtime 集中管理会话状态 | | **多端接入的一致性** | TUI、Web、Desktop、飞书、CI 脚本需要用统一协议接入,且行为一致 | Gateway 作为唯一 RPC 边界 + JSON-RPC 2.0 标准协议 | @@ -169,7 +169,7 @@ flowchart TD **第一步:构建上下文。** Runtime 把当前会话状态(消息历史、Todo 列表、激活的 Skills、已批准的 Plan)交给 Context 模块。Context 按固定顺序组装 System Prompt——核心行为准则、工具能力列表、项目规则、当前任务状态、Plan 上下文——然后返回给 Runtime。 -**Runtime 为什么不自己拼 Prompt。** Prompt 的组装逻辑是一个独立的关注点。上下文压缩(Compact)的策略——什么时候触发、用 MicroCompact 还是 Full Compact、哪些消息不能裁剪——需要在 Context 模块内独立演进。如果 Runtime 内嵌了 Prompt 拼接,修改压缩策略就需要改推理循环,两者耦合。 +**Runtime 为什么不自己拼 Prompt。** Prompt 的组装逻辑是一个独立的关注点。上下文压缩(Compact)的策略——什么时候触发、哪些消息不能裁剪——需要在 Context 模块内独立演进。如果 Runtime 内嵌了 Prompt 拼接,修改压缩策略就需要改推理循环,两者耦合。 **第二步:调用模型。** Runtime 把组装好的 Prompt 交给 Provider。Provider 是模型厂商的抽象层——它唯一的职责就是把不同厂商的 API 归一化为两个操作:估算 Token 数、发起流式推理。 @@ -345,7 +345,6 @@ flowchart LR **上下文裁剪(Compact)。** 当消息历史的 Token 数接近模型窗口上限时,Context 模块自动触发压缩: -- **MicroCompact**:移除较早的工具调用细节,保留摘要。优先裁剪输出最长的 Tool Result。 - **Full Compact**:调用 LLM 对整段历史生成摘要,替换原始消息列表。旧消息删除和新摘要插入在同一个 SQLite 事务中完成,保证原子性。 数据回流发生在两处:**工具结果回灌**(Tool Result 写入 Session Messages,供下一轮推理使用)和 **Compact 结果回写**(压缩后的摘要替换原始历史)。 diff --git a/docs/context-compact.md b/docs/context-compact.md index c93d7c57b..62257bf52 100644 --- a/docs/context-compact.md +++ b/docs/context-compact.md @@ -19,10 +19,8 @@ context: compact: manual_strategy: keep_recent manual_keep_recent_messages: 10 - micro_compact_retained_tool_spans: 6 read_time_max_message_spans: 24 max_summary_chars: 1200 - micro_compact_disabled: false budget: prompt_budget: 0 reserve_tokens: 13000 @@ -38,12 +36,8 @@ context: 在 `keep_recent` 模式下保留的最近消息数,并按 tool call / tool result 的原子块整体保留。 - `read_time_max_message_spans` 控制 `context.Builder` 读时 trim 可保留的 message span 上限。 -- `micro_compact_retained_tool_spans` - 控制 read-time micro compact 默认保留原始内容的最近可压缩工具块数量。 - `max_summary_chars` 控制 compact summary 的最大字符数。 -- `micro_compact_disabled` - 控制是否关闭默认启用的 read-time micro compact。 ### `context.budget` diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 229db91cc..c1b08047b 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -61,10 +61,8 @@ context: compact: manual_strategy: keep_recent manual_keep_recent_messages: 10 - micro_compact_retained_tool_spans: 6 read_time_max_message_spans: 24 max_summary_chars: 1200 - micro_compact_disabled: false budget: prompt_budget: 0 reserve_tokens: 13000 @@ -89,10 +87,8 @@ context: |------|------| | `context.compact.manual_strategy` | `/compact` 手动压缩策略,支持 `keep_recent` / `full_replace` | | `context.compact.manual_keep_recent_messages` | `keep_recent` 下保留的最近消息数 | -| `context.compact.micro_compact_retained_tool_spans` | read-time micro compact 默认保留原始内容的最近工具块数量 | | `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.budget` diff --git a/docs/roadmap.md b/docs/roadmap.md index fe6084393..f27a9d1f1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -11,7 +11,6 @@ | **传输层全 HTTP 化** | 当前 `transport/` 中残留的 Unix socket / Named pipe 逻辑增加了双平台代码路径和维护负担。统一到 HTTP JSON-RPC 后:第三方客户端接入更简单(只需要发 HTTP POST,不需要理解 Unix socket 地址规则)、Windows 和 Linux/macOS 的客户端连接逻辑完全一致 | 高——正在进行 | | **Gateway 大文件拆分** | `bootstrap.go` 超 1600 行,包含帧路由、认证、session CRUD、RPC 处理、流绑定等所有 Gateway 逻辑。5 人团队每人负责不同模块,但 Gateway 的改动集中在同一大文件中 → 持续的合并冲突。按功能域拆分为 `auth_handler.go`、`session_handler.go`、`stream_handler.go` 后,各自改自己的文件 | 高——直接影响并行开发效率 | | **Runner 工具并行执行** | Runner 当前串行处理 Gateway 下发的工具请求。在"手机飞书下指令 → 工位 Runner 执行"场景中,模型经常一次产出多个独立的 tool call(如同时读 3 个文件),串行执行导致不必要的延迟。改为并行执行可显著改善远程场景的响应体验 | 中——核心差异化场景的性能瓶颈 | -| **Compact 配置收敛** | MicroCompact 的 `MicroCompactConfig`、Full Compact 的 `CompactConfig`、预算阈值在 `RuntimeConfig` 中分散定义。调整上下文压缩策略时需要理解三个不同的配置入口,容易产生不一致的配置。收敛为单一 `CompactPolicy` 结构体 | 中——降低调优门槛 | ### 17.2 中期(巩固和放大现有差异化优势) diff --git a/docs/runtime-provider-event-flow.md b/docs/runtime-provider-event-flow.md index 5b9a12e0b..9d98fcaa2 100644 --- a/docs/runtime-provider-event-flow.md +++ b/docs/runtime-provider-event-flow.md @@ -88,7 +88,7 @@ runtime 不再消费旧的 builder 压缩建议,而是使用冻结快照上的 - 组装 `system prompt` - 读取 `AGENTS.md` - 注入 `Task State` / `Todo State` / `Skills` / `Memo` -- 执行 read-time trim 和 micro compact +- 执行 read-time trim - 输出最终 `SystemPrompt` 与消息列表 `context.Builder` 不再负责: diff --git a/docs/tech-debt.md b/docs/tech-debt.md index 4ace9ad13..31262d4dc 100644 --- a/docs/tech-debt.md +++ b/docs/tech-debt.md @@ -26,7 +26,6 @@ |--------|------|------|-------------| | **底层传输层 IPC 残留** | `internal/gateway/transport/` — Unix domain socket / Named pipe | 客户端连接路径复杂(需判断平台选 socket 类型),迁移到全 HTTP 后可消除 | 短期——已在迁移计划中 | | **`runtime/run.go` 单文件过长** | ReAct 主循环逻辑集中在 `run.go` (~400 行) 和 `runtime.go` (~540 行) | 新成员理解核心循环需要较长时间;修改风险集中在少数大文件中 | 中期——可按阶段拆分(pre-processing / loop body / termination) | -| **Compact 策略配置分散** | MicroCompact 配置在 `MicroCompactConfig`,Full Compact 在 `CompactConfig`,部分阈值在 `RuntimeConfig` | 调整上下文管理策略需要理解三处配置 | 中期——收敛为统一的 `CompactPolicy` 结构体 | | **Gateway Bootstrap 单文件** | `bootstrap.go` 超过 1600 行,包含帧路由、认证、session CRUD、RPC 处理 | 单体文件难以定位和维护 | 中期——拆分为 `session_handler.go`、`rpc_handler.go`、`auth_handler.go` | | **Acceptance 测试耗时长** | `runtime/acceptance/` 的端到端测试依赖真实模型 API | CI 成本高、不稳定(网络波动导致 flaky) | 长期——增加录制/回放(VCR)模式,CI 中默认使用录制的 fixture | diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 05545b48b..45fcd3c17 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -179,14 +179,7 @@ func BuildGatewayServerDeps(ctx context.Context, opts BootstrapOptions) (Runtime log.Printf("session cleanup warning: %v", err) } - // 注册内置工具的内容摘要器,使 micro-compact 在清理旧工具结果时保留关键上下文。 - tools.RegisterBuiltinSummarizers(toolRegistry) - - microCompactCfg := agentcontext.MicroCompactConfig{ - Policies: toolRegistry, - Summarizers: toolRegistry, - } - var contextBuilder agentcontext.Builder = agentcontext.NewConfiguredBuilder(microCompactCfg) + var contextBuilder agentcontext.Builder = agentcontext.NewConfiguredBuilder() var memoSvc *memo.Service if cfg.Memo.Enabled { memoStore := memo.NewFileStore(sharedDeps.ConfigManager.BaseDir(), cfg.Workdir) @@ -195,7 +188,7 @@ func BuildGatewayServerDeps(ctx context.Context, opts BootstrapOptions) (Runtime if invalidator, ok := memoSource.(interface{ InvalidateCache() }); ok { sourceInvl = invalidator.InvalidateCache } - contextBuilder = agentcontext.NewConfiguredBuilder(microCompactCfg, memoSource) + contextBuilder = agentcontext.NewConfiguredBuilder(memoSource) memoSvc = memo.NewService(memoStore, cfg.Memo, sourceInvl) toolRegistry.Register(memotool.NewRememberTool(memoSvc)) toolRegistry.Register(memotool.NewRecallTool(memoSvc)) diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index a1af4c49d..11dc335c1 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -2184,9 +2184,6 @@ func (s *stubRemoteRuntimeForBootstrap) Close() error { 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/config/config_test.go b/internal/config/config_test.go index c6a6cfd50..4db239595 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1129,15 +1129,11 @@ func TestCompactConfigDefaultsAndRoundTrip(t *testing.T) { compactCfg.ReadTimeMaxMessageSpans, ) } - 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.ReadTimeMaxMessageSpans = 30 - cfg.Context.Compact.MicroCompactDisabled = true if err := loader.Save(context.Background(), cfg); err != nil { t.Fatalf("Save() error = %v", err) } @@ -1152,9 +1148,6 @@ 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) - } 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) } @@ -1175,9 +1168,6 @@ func TestCompactConfigDefaultsAndRoundTrip(t *testing.T) { 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") - } } func TestCompactConfigValidateFailures(t *testing.T) { diff --git a/internal/config/context.go b/internal/config/context.go index 139848bdd..7b0b68b77 100644 --- a/internal/config/context.go +++ b/internal/config/context.go @@ -13,7 +13,6 @@ const ( DefaultBudgetReserveTokens = 13000 DefaultBudgetFallbackPromptBudget = 100000 DefaultBudgetMaxReactiveCompacts = 3 - DefaultMicroCompactRetainedToolSpans = 6 DefaultCompactReadTimeMaxMessageSpans = 24 DefaultAskMaxInputTokens = 8000 DefaultAskRetainTurns = 5 @@ -30,13 +29,11 @@ type ContextConfig struct { } 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"` - 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"` + ManualStrategy string `yaml:"manual_strategy,omitempty"` + ManualKeepRecentMessages int `yaml:"manual_keep_recent_messages,omitempty"` + MaxSummaryChars int `yaml:"max_summary_chars,omitempty"` + ReadTimeMaxMessageSpans int `yaml:"read_time_max_message_spans,omitempty"` + MaxArchivedPromptChars int `yaml:"max_archived_prompt_chars,omitempty"` } // BudgetConfig 定义上下文预算控制面的配置。 @@ -79,7 +76,6 @@ func defaultCompactConfig() CompactConfig { ManualStrategy: CompactManualStrategyKeepRecent, ManualKeepRecentMessages: DefaultCompactManualKeepRecentMessages, MaxSummaryChars: DefaultCompactMaxSummaryChars, - MicroCompactRetainedToolSpans: DefaultMicroCompactRetainedToolSpans, ReadTimeMaxMessageSpans: DefaultCompactReadTimeMaxMessageSpans, } } @@ -143,9 +139,6 @@ func (c *CompactConfig) ApplyDefaults(defaults CompactConfig) { if c.MaxSummaryChars <= 0 { c.MaxSummaryChars = defaults.MaxSummaryChars } - if c.MicroCompactRetainedToolSpans <= 0 { - c.MicroCompactRetainedToolSpans = defaults.MicroCompactRetainedToolSpans - } if c.ReadTimeMaxMessageSpans <= 0 { c.ReadTimeMaxMessageSpans = defaults.ReadTimeMaxMessageSpans } diff --git a/internal/config/loader.go b/internal/config/loader.go index a07a689c9..940703fdd 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -43,13 +43,11 @@ type persistedContextConfig struct { } 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"` - 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"` + ManualStrategy string `yaml:"manual_strategy,omitempty"` + ManualKeepRecentMessages int `yaml:"manual_keep_recent_messages,omitempty"` + MaxSummaryChars int `yaml:"max_summary_chars,omitempty"` + ReadTimeMaxMessageSpans int `yaml:"read_time_max_message_spans,omitempty"` + MaxArchivedPromptChars int `yaml:"max_archived_prompt_chars,omitempty"` } type persistedBudgetConfig struct { @@ -284,13 +282,11 @@ func marshalPersistedConfig(snapshot Config) ([]byte, error) { func newPersistedContextConfig(cfg ContextConfig) persistedContextConfig { return persistedContextConfig{ Compact: persistedCompactConfig{ - ManualStrategy: cfg.Compact.ManualStrategy, - ManualKeepRecentMessages: cfg.Compact.ManualKeepRecentMessages, - MaxSummaryChars: cfg.Compact.MaxSummaryChars, - MicroCompactDisabled: cfg.Compact.MicroCompactDisabled, - MicroCompactRetainedToolSpans: cfg.Compact.MicroCompactRetainedToolSpans, - ReadTimeMaxMessageSpans: cfg.Compact.ReadTimeMaxMessageSpans, - MaxArchivedPromptChars: cfg.Compact.MaxArchivedPromptChars, + ManualStrategy: cfg.Compact.ManualStrategy, + ManualKeepRecentMessages: cfg.Compact.ManualKeepRecentMessages, + MaxSummaryChars: cfg.Compact.MaxSummaryChars, + ReadTimeMaxMessageSpans: cfg.Compact.ReadTimeMaxMessageSpans, + MaxArchivedPromptChars: cfg.Compact.MaxArchivedPromptChars, }, Budget: persistedBudgetConfig{ PromptBudget: cfg.Budget.PromptBudget, @@ -310,13 +306,11 @@ func newPersistedContextConfig(cfg ContextConfig) persistedContextConfig { func fromPersistedContextConfig(file persistedContextConfig, defaults ContextConfig) ContextConfig { out := ContextConfig{ Compact: CompactConfig{ - ManualStrategy: strings.TrimSpace(file.Compact.ManualStrategy), - ManualKeepRecentMessages: file.Compact.ManualKeepRecentMessages, - MaxSummaryChars: file.Compact.MaxSummaryChars, - MicroCompactDisabled: file.Compact.MicroCompactDisabled, - MicroCompactRetainedToolSpans: file.Compact.MicroCompactRetainedToolSpans, - ReadTimeMaxMessageSpans: file.Compact.ReadTimeMaxMessageSpans, - MaxArchivedPromptChars: file.Compact.MaxArchivedPromptChars, + ManualStrategy: strings.TrimSpace(file.Compact.ManualStrategy), + ManualKeepRecentMessages: file.Compact.ManualKeepRecentMessages, + MaxSummaryChars: file.Compact.MaxSummaryChars, + ReadTimeMaxMessageSpans: file.Compact.ReadTimeMaxMessageSpans, + MaxArchivedPromptChars: file.Compact.MaxArchivedPromptChars, }, Budget: BudgetConfig{ PromptBudget: file.Budget.PromptBudget, diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 1ebdca32d..bc5fda873 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -2106,7 +2106,6 @@ context: manual_strategy: keep_recent manual_keep_recent_messages: 9 max_summary_chars: 900 - micro_compact_retained_tool_spans: 4 max_archived_prompt_chars: 4096 ` writeLoaderConfig(t, loader, raw) @@ -2115,9 +2114,6 @@ context: if err != nil { t.Fatalf("Load() error = %v", err) } - if cfg.Context.Compact.MicroCompactRetainedToolSpans != 4 { - t.Fatalf("expected micro_compact_retained_tool_spans=4, got %d", cfg.Context.Compact.MicroCompactRetainedToolSpans) - } if cfg.Context.Compact.MaxArchivedPromptChars != 4096 { t.Fatalf("expected max_archived_prompt_chars=4096, got %d", cfg.Context.Compact.MaxArchivedPromptChars) } @@ -2128,7 +2124,6 @@ func TestLoaderSaveRoundTripsCompactExtendedFields(t *testing.T) { loader := NewLoader(t.TempDir(), testDefaultConfig()) cfg := loader.DefaultConfig() - cfg.Context.Compact.MicroCompactRetainedToolSpans = 5 cfg.Context.Compact.MaxArchivedPromptChars = 3072 if err := loader.Save(context.Background(), &cfg); err != nil { @@ -2140,9 +2135,6 @@ func TestLoaderSaveRoundTripsCompactExtendedFields(t *testing.T) { t.Fatalf("read config: %v", err) } text := string(data) - if !strings.Contains(text, "micro_compact_retained_tool_spans: 5") { - t.Fatalf("expected persisted micro_compact_retained_tool_spans, got:\n%s", text) - } if !strings.Contains(text, "max_archived_prompt_chars: 3072") { t.Fatalf("expected persisted max_archived_prompt_chars, got:\n%s", text) } @@ -2151,9 +2143,6 @@ func TestLoaderSaveRoundTripsCompactExtendedFields(t *testing.T) { if err != nil { t.Fatalf("Load() error = %v", err) } - if loaded.Context.Compact.MicroCompactRetainedToolSpans != 5 { - t.Fatalf("expected round-trip micro_compact_retained_tool_spans=5, got %d", loaded.Context.Compact.MicroCompactRetainedToolSpans) - } if loaded.Context.Compact.MaxArchivedPromptChars != 3072 { t.Fatalf("expected round-trip max_archived_prompt_chars=3072, got %d", loaded.Context.Compact.MaxArchivedPromptChars) } diff --git a/internal/context/builder.go b/internal/context/builder.go index 0ab6e2f08..cb7e0da16 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -3,8 +3,6 @@ package context import ( "context" - providertypes "neo-code/internal/provider/types" - agentsession "neo-code/internal/session" ) // DefaultBuilder preserves the current runtime context-building behavior. @@ -13,7 +11,6 @@ type DefaultBuilder struct { dynamicPromptSources []promptSectionSource promptSources []promptSectionSource trimPolicy messageTrimPolicy - microCompactCfg MicroCompactConfig } // newStablePromptSources 返回稳定提示词来源列表,适合作为缓存前缀。 @@ -44,52 +41,19 @@ func newDynamicPromptSources() []promptSectionSource { } } -// NewConfiguredBuilder 基于聚合配置和可选 SectionSource 列表构建上下文构建器,是推荐的统一构造入口。 -// cfg.PinChecker 为 nil 时自动使用默认 pin checker;sources 中 nil 元素会被跳过。 -func NewConfiguredBuilder(cfg MicroCompactConfig, sources ...SectionSource) Builder { - if cfg.PinChecker == nil { - cfg.PinChecker = NewDefaultPinChecker() - } +// NewConfiguredBuilder 基于可选 SectionSource 列表构建上下文构建器,是推荐的统一构造入口。 +// sources 中 nil 元素会被跳过。 +func NewConfiguredBuilder(sources ...SectionSource) Builder { return &DefaultBuilder{ stablePromptSources: newStablePromptSources(sources...), dynamicPromptSources: newDynamicPromptSources(), trimPolicy: spanMessageTrimPolicy{}, - microCompactCfg: cfg, } } // NewBuilder returns the default context builder implementation. func NewBuilder() Builder { - return NewConfiguredBuilder(MicroCompactConfig{}) -} - -// NewBuilderWithToolPolicies 返回带工具 micro compact 策略源的默认上下文构建器。 -// -// Deprecated: 使用 NewConfiguredBuilder 替代。 -func NewBuilderWithToolPolicies(policies MicroCompactPolicySource) Builder { - return NewConfiguredBuilder(MicroCompactConfig{Policies: policies}) -} - -// NewBuilderWithToolPoliciesAndSummarizers 返回带工具策略与内容摘要器的上下文构建器。 -// -// Deprecated: 使用 NewConfiguredBuilder 替代。 -func NewBuilderWithToolPoliciesAndSummarizers(policies MicroCompactPolicySource, summarizers MicroCompactSummarizerSource) Builder { - return NewConfiguredBuilder(MicroCompactConfig{Policies: policies, Summarizers: summarizers}) -} - -// NewBuilderWithMemo 返回带记忆注入能力的上下文构建器。 -// memoSource 为 nil 时等价于 NewBuilderWithToolPolicies。 -// -// Deprecated: 使用 NewConfiguredBuilder 替代。 -func NewBuilderWithMemo(policies MicroCompactPolicySource, memoSource SectionSource) Builder { - return NewConfiguredBuilder(MicroCompactConfig{Policies: policies}, memoSource) -} - -// NewBuilderWithMemoAndSummarizers 返回带记忆注入与内容摘要器的上下文构建器。 -// -// Deprecated: 使用 NewConfiguredBuilder 替代。 -func NewBuilderWithMemoAndSummarizers(policies MicroCompactPolicySource, summarizers MicroCompactSummarizerSource, memoSource SectionSource) Builder { - return NewConfiguredBuilder(MicroCompactConfig{Policies: policies, Summarizers: summarizers}, memoSource) + return NewConfiguredBuilder() } // collectPromptSections 遍历 promptSectionSource 列表并收集所有 sections。 @@ -141,46 +105,13 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu if trimPolicy == nil { trimPolicy = spanMessageTrimPolicy{} } - pinChecker := b.microCompactCfg.PinChecker - if pinChecker == nil { - pinChecker = NewDefaultPinChecker() - } return BuildResult{ SystemPrompt: systemPrompt, StableSystemPrompt: stablePrompt, DynamicSystemPrompt: dynamicPrompt, - Messages: applyReadTimeContextProjection( + Messages: ProjectToolMessagesForModel( trimPolicy.Trim(input.Messages, input.Compact), - input.TaskState, - input.Compact, - b.microCompactCfg.Policies, - b.microCompactCfg.Summarizers, - pinChecker, ), }, nil } - -// applyReadTimeContextProjection 负责在 provider 读取路径上应用只读上下文投影,避免改写原始会话消息。 -func applyReadTimeContextProjection( - messages []providertypes.Message, - taskState agentsession.TaskState, - options CompactOptions, - policies MicroCompactPolicySource, - summarizers MicroCompactSummarizerSource, - pinChecker MicroCompactPinChecker, -) []providertypes.Message { - projectedMessages := cloneContextMessages(messages) - if options.DisableMicroCompact || !taskState.Established() { - return ProjectToolMessagesForModel(projectedMessages) - } - - projectedMessages = microCompactMessagesWithPolicies( - projectedMessages, - policies, - options.MicroCompactRetainedToolSpans, - summarizers, - pinChecker, - ) - return ProjectToolMessagesForModel(projectedMessages) -} diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index 8f1f01c65..a6a3417eb 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "reflect" "strings" "testing" "time" @@ -15,7 +14,6 @@ import ( providertypes "neo-code/internal/provider/types" "neo-code/internal/rules" agentsession "neo-code/internal/session" - "neo-code/internal/tools" ) const maxRetainedMessageSpans = config.DefaultCompactReadTimeMaxMessageSpans @@ -249,28 +247,6 @@ func TestDefaultBuilderBuildIncludesTodosBeforeSystemState(t *testing.T) { } } -func TestNewBuilderWithMemoAndSummarizersIncludesMemoSection(t *testing.T) { - t.Parallel() - - builder := NewBuilderWithMemoAndSummarizers(nil, nil, stubPromptSectionSource{ - sections: []promptSection{ - NewPromptSection("memo", "remember this"), - }, - }) - - got, err := builder.Build(stdcontext.Background(), BuildInput{ - Messages: []providertypes.Message{ - {Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}, - }, - Metadata: testMetadata(t.TempDir()), - }) - if err != nil { - t.Fatalf("Build() error = %v", err) - } - if !strings.Contains(got.SystemPrompt, "## memo") { - t.Fatalf("expected memo section in prompt, got %q", got.SystemPrompt) - } -} func TestDefaultBuilderBuildPlacesRulesBeforeMemo(t *testing.T) { t.Parallel() @@ -294,8 +270,7 @@ func TestDefaultBuilderBuildPlacesRulesBeforeMemo(t *testing.T) { stubPromptSectionSource{sections: []promptSection{{Title: "Memo", Content: "remember this"}}}, &systemStateSource{}, }, - microCompactCfg: MicroCompactConfig{PinChecker: NewDefaultPinChecker()}, - } + } got, err := builder.Build(stdcontext.Background(), BuildInput{ Messages: []providertypes.Message{ @@ -341,15 +316,13 @@ func TestDefaultBuilderBuildUsesSpanTrimPolicyWhenTrimPolicyIsUnset(t *testing.T promptSources: []promptSectionSource{ stubPromptSectionSource{sections: []promptSection{{Title: "Stub", Content: "body"}}}, }, - microCompactCfg: MicroCompactConfig{PinChecker: NewDefaultPinChecker()}, - } + } got, err := builder.Build(stdcontext.Background(), BuildInput{ Messages: messages, TaskState: agentsession.TaskState{Goal: "keep implementing task"}, Compact: CompactOptions{ - MicroCompactRetainedToolSpans: 2, - }, + }, }) if err != nil { t.Fatalf("Build() error = %v", err) @@ -377,190 +350,15 @@ func TestDefaultBuilderBuildReturnsPromptSourceError(t *testing.T) { } } -func TestDefaultBuilderBuildAppliesMicroCompactAfterTrim(t *testing.T) { - t.Parallel() - - builder := &DefaultBuilder{ - promptSources: []promptSectionSource{ - stubPromptSectionSource{sections: []promptSection{{Title: "Stub", Content: "body"}}}, - }, - microCompactCfg: MicroCompactConfig{PinChecker: NewDefaultPinChecker()}, - } - - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old read result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: "webfetch", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("current reply")}}, - } - - got, err := builder.Build(stdcontext.Background(), BuildInput{ - Messages: messages, - TaskState: agentsession.TaskState{Goal: "keep implementing task"}, - Compact: CompactOptions{ - MicroCompactRetainedToolSpans: 2, - }, - }) - 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 !strings.Contains(renderDisplayParts(got.Messages[2].Parts), "[summary] filesystem_read_file") { - t.Fatalf("expected builder output to summarize older tool result, got %q", renderDisplayParts(got.Messages[2].Parts)) - } - if renderDisplayParts(got.Messages[4].Parts) != "recent bash result" { - t.Fatalf("expected recent tool result to stay visible, got %q", renderDisplayParts(got.Messages[4].Parts)) - } - if renderDisplayParts(got.Messages[6].Parts) != "latest webfetch result" { - t.Fatalf("expected latest tool result to stay visible, got %q", renderDisplayParts(got.Messages[6].Parts)) - } -} - -func TestDefaultBuilderBuildDefaultsPinCheckerForLiteralBuilder(t *testing.T) { - t.Parallel() - - builder := &DefaultBuilder{ - promptSources: []promptSectionSource{ - stubPromptSectionSource{sections: []promptSection{{Title: "Stub", Content: "body"}}}, - }, - microCompactCfg: MicroCompactConfig{PinChecker: NewDefaultPinChecker()}, - } - - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "filesystem_write_file", Arguments: `{"path":"README.md"}`}, - }, - }, - { - Role: providertypes.RoleTool, - ToolCallID: "call-1", - Parts: []providertypes.ContentPart{providertypes.NewTextPart("README content")}, - ToolMetadata: map[string]string{ - "path": "/project/README.md", - }, - }, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("current reply")}}, - } - - got, err := builder.Build(stdcontext.Background(), BuildInput{ - Messages: messages, - TaskState: agentsession.TaskState{Goal: "keep implementing task"}, - Compact: CompactOptions{ - MicroCompactRetainedToolSpans: 1, - }, - }) - if err != nil { - t.Fatalf("Build() error = %v", err) - } - projectedText := renderDisplayParts(got.Messages[2].Parts) - if projectedText == microCompactClearedMessage { - t.Fatalf("expected pinned README result to avoid cleared placeholder, got %q", projectedText) - } - if !strings.Contains(projectedText, "README content") { - t.Fatalf("expected pinned README result to retain content, got %q", projectedText) - } -} - -func TestDefaultBuilderBuildRespectsExplicitPinCheckerOverride(t *testing.T) { - t.Parallel() - builder := &DefaultBuilder{ - promptSources: []promptSectionSource{ - stubPromptSectionSource{sections: []promptSection{{Title: "Stub", Content: "body"}}}, - }, - microCompactCfg: MicroCompactConfig{PinChecker: noopPinChecker{}}, - } - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "filesystem_write_file", Arguments: `{"path":"README.md"}`}, - }, - }, - { - Role: providertypes.RoleTool, - ToolCallID: "call-1", - Parts: []providertypes.ContentPart{providertypes.NewTextPart("README content")}, - ToolMetadata: map[string]string{ - "path": "/project/README.md", - }, - }, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("current reply")}}, - } - got, err := builder.Build(stdcontext.Background(), BuildInput{ - Messages: messages, - TaskState: agentsession.TaskState{Goal: "keep implementing task"}, - Compact: CompactOptions{ - MicroCompactRetainedToolSpans: 1, - }, - }) - if err != nil { - t.Fatalf("Build() error = %v", err) - } - if !strings.Contains(renderDisplayParts(got.Messages[2].Parts), "[summary] filesystem_write_file") { - t.Fatalf("expected explicit noop pin checker to allow compaction into summary, got %q", renderDisplayParts(got.Messages[2].Parts)) - } -} - -type noopPinChecker struct{} -func (noopPinChecker) ShouldPin(string, map[string]string) bool { return false } -func TestNewBuilderWithToolPoliciesAndSummarizers(t *testing.T) { +func TestNewBuilder(t *testing.T) { t.Parallel() - builder := NewBuilderWithToolPoliciesAndSummarizers( - nil, - stubMicroCompactSummarizerSource{ - "filesystem_read_file": func(content string, metadata map[string]string, isError bool) string { - return "[summary] read_file" - }, - }, - ) + builder := NewBuilder() messages := []providertypes.Message{ {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, @@ -592,202 +390,24 @@ func TestNewBuilderWithToolPoliciesAndSummarizers(t *testing.T) { Messages: messages, TaskState: agentsession.TaskState{Goal: "keep implementing task"}, Compact: CompactOptions{ - MicroCompactRetainedToolSpans: 2, - }, + }, Metadata: testMetadata(t.TempDir()), }) if err != nil { t.Fatalf("Build() error = %v", err) } - const summarizedMessageIndex = 2 - if renderDisplayParts(got.Messages[summarizedMessageIndex].Parts) != "[summary] read_file" { + const olderReadIndex = 2 + if renderDisplayParts(got.Messages[olderReadIndex].Parts) != "old read result" { t.Fatalf( - "expected summarized older read result, got %q", - renderDisplayParts(got.Messages[summarizedMessageIndex].Parts), + "expected older read result content, got %q", + renderDisplayParts(got.Messages[olderReadIndex].Parts), ) } } -func TestDefaultBuilderBuildSkipsMicroCompactWithoutEstablishedTaskState(t *testing.T) { - t.Parallel() - - builder := &DefaultBuilder{ - promptSources: []promptSectionSource{ - stubPromptSectionSource{sections: []promptSection{{Title: "Stub", Content: "body"}}}, - }, - microCompactCfg: MicroCompactConfig{PinChecker: NewDefaultPinChecker()}, - } - - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old read result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - } - - got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages}) - if err != nil { - t.Fatalf("Build() error = %v", err) - } - if renderDisplayParts(got.Messages[2].Parts) != "old read result" { - t.Fatalf("expected old tool result to remain visible without task state, got %q", renderDisplayParts(got.Messages[2].Parts)) - } -} - -func TestDefaultBuilderBuildSkipsMicroCompactWhenDisabled(t *testing.T) { - t.Parallel() - - builder := &DefaultBuilder{ - promptSources: []promptSectionSource{ - stubPromptSectionSource{sections: []promptSection{{Title: "Stub", Content: "body"}}}, - }, - microCompactCfg: MicroCompactConfig{PinChecker: NewDefaultPinChecker()}, - } - - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old read result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: "webfetch", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("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 TestDefaultBuilderBuildHonorsToolMicroCompactPolicies(t *testing.T) { - t.Parallel() - builder := &DefaultBuilder{ - promptSources: []promptSectionSource{ - stubPromptSectionSource{sections: []promptSection{{Title: "Stub", Content: "body"}}}, - }, - microCompactCfg: MicroCompactConfig{ - Policies: stubMicroCompactPolicySource{"custom_tool": tools.MicroCompactPolicyPreserveHistory}, - PinChecker: NewDefaultPinChecker(), - }, - } - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "custom_tool", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old custom result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: "webfetch", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - } - - got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages}) - if err != nil { - t.Fatalf("Build() error = %v", err) - } - if renderDisplayParts(got.Messages[2].Parts) != "old custom result" { - t.Fatalf("expected preserved tool result to remain, got %q", renderDisplayParts(got.Messages[2].Parts)) - } -} - -func TestNewBuilderWithToolPoliciesUsesProvidedPolicySource(t *testing.T) { - t.Parallel() - - builder := NewBuilderWithToolPolicies(stubMicroCompactPolicySource{ - "custom_tool": tools.MicroCompactPolicyPreserveHistory, - }) - - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "custom_tool", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old custom result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: "webfetch", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - } - got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages}) - if err != nil { - t.Fatalf("Build() error = %v", err) - } - if renderDisplayParts(got.Messages[2].Parts) != "old custom result" { - t.Fatalf("expected preserved tool result to remain, got %q", renderDisplayParts(got.Messages[2].Parts)) - } -} func TestTrimMessagesPreservesToolPairs(t *testing.T) { t.Parallel() @@ -996,51 +616,13 @@ func TestTrimMessagesBoundaries(t *testing.T) { } } -func TestNewBuilderWithMemo(t *testing.T) { - t.Parallel() - - t.Run("with memo source injects memo section", func(t *testing.T) { - memoSource := stubPromptSectionSource{ - sections: []promptSection{{Title: "Memo", Content: "- [user] test entry"}}, - } - builder := NewBuilderWithMemo(stubMicroCompactPolicySource{}, memoSource) - input := BuildInput{ - Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, - Metadata: testMetadata(t.TempDir()), - } - result, err := builder.Build(stdcontext.Background(), input) - if err != nil { - t.Fatalf("Build() error = %v", err) - } - if !strings.Contains(result.SystemPrompt, "## Memo") { - t.Errorf("expected Memo section in system prompt") - } - if !strings.Contains(result.SystemPrompt, "test entry") { - t.Errorf("expected memo content in system prompt") - } - }) - t.Run("nil memo source skips memo section", func(t *testing.T) { - builder := NewBuilderWithMemo(stubMicroCompactPolicySource{}, nil) - input := BuildInput{ - Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, - Metadata: testMetadata(t.TempDir()), - } - result, err := builder.Build(stdcontext.Background(), input) - if err != nil { - t.Fatalf("Build() error = %v", err) - } - if strings.Contains(result.SystemPrompt, "## Memo") { - t.Error("nil memo source should not inject Memo section") - } - }) -} func TestNewConfiguredBuilder(t *testing.T) { t.Parallel() t.Run("empty config defaults pin checker", func(t *testing.T) { - builder := NewConfiguredBuilder(MicroCompactConfig{}) + builder := NewConfiguredBuilder() input := BuildInput{ Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, Metadata: testMetadata(t.TempDir()), @@ -1054,106 +636,13 @@ func TestNewConfiguredBuilder(t *testing.T) { } }) - t.Run("with policies and summarizers", func(t *testing.T) { - cfg := MicroCompactConfig{ - Policies: stubMicroCompactPolicySource{}, - Summarizers: stubMicroCompactSummarizerSource{ - "filesystem_read_file": func(content string, metadata map[string]string, isError bool) string { - return "[summary] read_file" - }, - }, - } - builder := NewConfiguredBuilder(cfg) - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old read result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest bash result")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - } - got, err := builder.Build(stdcontext.Background(), BuildInput{ - Messages: messages, - TaskState: agentsession.TaskState{Goal: "keep implementing task"}, - Compact: CompactOptions{ - MicroCompactRetainedToolSpans: 2, - }, - Metadata: testMetadata(t.TempDir()), - }) - if err != nil { - t.Fatalf("Build() error = %v", err) - } - if renderDisplayParts(got.Messages[2].Parts) != "[summary] read_file" { - t.Fatalf("expected summarized older read result, got %q", renderDisplayParts(got.Messages[2].Parts)) - } - }) - t.Run("with custom pin checker", func(t *testing.T) { - cfg := MicroCompactConfig{ - PinChecker: noopPinChecker{}, - } - builder := NewConfiguredBuilder(cfg) - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "filesystem_write_file", Arguments: `{"path":"README.md"}`}, - }, - }, - { - Role: providertypes.RoleTool, - ToolCallID: "call-1", - Parts: []providertypes.ContentPart{providertypes.NewTextPart("README content")}, - ToolMetadata: map[string]string{"path": "/project/README.md"}, - }, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("current reply")}}, - } - got, err := builder.Build(stdcontext.Background(), BuildInput{ - Messages: messages, - TaskState: agentsession.TaskState{Goal: "keep implementing task"}, - Compact: CompactOptions{ - MicroCompactRetainedToolSpans: 1, - }, - }) - if err != nil { - t.Fatalf("Build() error = %v", err) - } - if !strings.Contains(renderDisplayParts(got.Messages[2].Parts), "[summary] filesystem_write_file") { - t.Fatalf("expected noop pin checker to allow compaction into summary, got %q", renderDisplayParts(got.Messages[2].Parts)) - } - }) t.Run("with extra section sources", func(t *testing.T) { extraSource := stubPromptSectionSource{ sections: []promptSection{{Title: "Custom", Content: "custom section body"}}, } - builder := NewConfiguredBuilder(MicroCompactConfig{}, extraSource) + builder := NewConfiguredBuilder(extraSource) input := BuildInput{ Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, Metadata: testMetadata(t.TempDir()), @@ -1171,7 +660,7 @@ func TestNewConfiguredBuilder(t *testing.T) { }) t.Run("nil section sources are skipped", func(t *testing.T) { - builder := NewConfiguredBuilder(MicroCompactConfig{}, nil, stubPromptSectionSource{ + builder := NewConfiguredBuilder(nil, stubPromptSectionSource{ sections: []promptSection{{Title: "Extra", Content: "extra body"}}, }, nil) input := BuildInput{ @@ -1338,7 +827,7 @@ func TestDefaultBuilderBuildTodoChangeDoesNotChangeStablePrompt(t *testing.T) { func TestDefaultBuilderBuildMemoIsStable(t *testing.T) { t.Parallel() - builder := NewConfiguredBuilder(MicroCompactConfig{}, stubPromptSectionSource{ + builder := NewConfiguredBuilder(stubPromptSectionSource{ sections: []promptSection{ NewPromptSection("memo", "remember this"), }, @@ -1369,8 +858,7 @@ func TestDefaultBuilderBuildStableAndDynamicPreservesBackwardCompat(t *testing.T promptSources: []promptSectionSource{ stubPromptSectionSource{sections: []promptSection{{Title: "Old", Content: "old style"}}}, }, - microCompactCfg: MicroCompactConfig{PinChecker: NewDefaultPinChecker()}, - } + } result, err := builder.Build(stdcontext.Background(), BuildInput{}) if err != nil { diff --git a/internal/context/microcompact.go b/internal/context/microcompact.go deleted file mode 100644 index 050a543ee..000000000 --- a/internal/context/microcompact.go +++ /dev/null @@ -1,331 +0,0 @@ -package context - -import ( - "strconv" - "strings" - "unicode/utf8" - - "neo-code/internal/config" - "neo-code/internal/context/internalcompact" - providertypes "neo-code/internal/provider/types" - "neo-code/internal/tools" -) - -const ( - // microCompactClearedMessage 是旧工具结果被读时微压缩后的占位符文本。 - microCompactClearedMessage = "[Old tool result content cleared]" - // microCompactSummaryMaxRunes 是摘要回灌到上下文前允许的最大 rune 数量。 - microCompactSummaryMaxRunes = 200 -) - -// microCompactMessages 对裁剪后的消息做只读投影式微压缩,优先摘要旧工具结果,失败时回退清理占位。 -func microCompactMessages(messages []providertypes.Message) []providertypes.Message { - return microCompactMessagesWithPolicies(messages, nil, 0, nil, nil) -} - -// microCompactMessagesWithPolicies 按工具策略对裁剪后的消息做只读投影式微压缩。 -// 仅对需要压缩的工具消息做深拷贝,其余消息共享原始引用以减少内存分配。 -func microCompactMessagesWithPolicies(messages []providertypes.Message, policies MicroCompactPolicySource, retainedToolSpans int, summarizers MicroCompactSummarizerSource, pinChecker MicroCompactPinChecker) []providertypes.Message { - if retainedToolSpans <= 0 { - retainedToolSpans = config.DefaultMicroCompactRetainedToolSpans - } - - if len(messages) == 0 { - return nil - } - - spans := internalcompact.BuildMessageSpans(messages) - protectedStart, hasProtectedTail := internalcompact.ProtectedTailStart(spans) - retainedCompactableSpans := 0 - - modifiedIndices := make(map[int]struct{}) - var pendingCompactions []compactionPending - - for spanIndex := len(spans) - 1; spanIndex >= 0; spanIndex-- { - span := spans[spanIndex] - if hasProtectedTail && span.Start >= protectedStart { - continue - } - if !isToolCallSpan(messages, span) { - continue - } - - compactableIDs, toolNames := compactableToolCallIDs(messages[span.Start].ToolCalls, policies) - if len(compactableIDs) == 0 { - continue - } - if retainedCompactableSpans < retainedToolSpans { - if hasCompactableToolMessage(messages, span, compactableIDs, toolNames, pinChecker) { - retainedCompactableSpans++ - } - continue - } - - compactableContents := compactableToolMessageContents(messages, span, compactableIDs, toolNames, pinChecker) - if len(compactableContents) == 0 { - continue - } - - for messageIndex, content := range compactableContents { - modifiedIndices[messageIndex] = struct{}{} - pendingCompactions = append(pendingCompactions, compactionPending{ - index: messageIndex, - content: content, - toolNames: toolNames, - }) - } - } - - if len(modifiedIndices) == 0 { - return append([]providertypes.Message(nil), messages...) - } - - cloned := make([]providertypes.Message, len(messages)) - for i, msg := range messages { - if _, needsClone := modifiedIndices[i]; needsClone { - cloned[i] = cloneSingleMessage(msg) - } else { - cloned[i] = msg - } - } - - for _, pending := range pendingCompactions { - summary := summarizeOrClear(cloned[pending.index], pending.content, pending.toolNames, summarizers) - cloned[pending.index].Parts = []providertypes.ContentPart{providertypes.NewTextPart(summary)} - } - - return cloned -} - -// compactionPending 记录待压缩的消息索引和所需上下文。 -type compactionPending struct { - index int - content string - toolNames map[string]string -} - -// cloneContextMessages 深拷贝消息切片,避免读时投影污染 runtime 持有的原始会话消息。 -func cloneContextMessages(messages []providertypes.Message) []providertypes.Message { - if len(messages) == 0 { - return nil - } - - cloned := make([]providertypes.Message, 0, len(messages)) - for _, message := range messages { - cloned = append(cloned, cloneSingleMessage(message)) - } - return cloned -} - -// cloneSingleMessage 深拷贝单条消息,隔离 ToolCalls 和 ToolMetadata 的底层引用。 -func cloneSingleMessage(msg providertypes.Message) providertypes.Message { - next := msg - next.ToolCalls = append([]providertypes.ToolCall(nil), msg.ToolCalls...) - if len(msg.ToolMetadata) > 0 { - next.ToolMetadata = make(map[string]string, len(msg.ToolMetadata)) - for key, value := range msg.ToolMetadata { - next.ToolMetadata[key] = value - } - } - return next -} - -// isToolCallSpan 判断当前 span 是否是由 assistant tool call 起始的原子工具块。 -func isToolCallSpan(messages []providertypes.Message, span internalcompact.MessageSpan) bool { - if span.Start < 0 || span.Start >= len(messages) { - return false - } - message := messages[span.Start] - return message.Role == providertypes.RoleAssistant && len(message.ToolCalls) > 0 -} - -// compactableToolCallIDs 返回 assistant tool call 中可参与微压缩的调用 ID 集合及对应的工具名映射。 -func compactableToolCallIDs(calls []providertypes.ToolCall, policies MicroCompactPolicySource) (map[string]struct{}, map[string]string) { - if len(calls) == 0 { - return nil, nil - } - - ids := make(map[string]struct{}, len(calls)) - toolNames := make(map[string]string, len(calls)) - for _, call := range calls { - toolName := strings.TrimSpace(call.Name) - if !toolParticipatesInMicroCompact(toolName, policies) { - continue - } - callID := strings.TrimSpace(call.ID) - if callID == "" { - continue - } - ids[callID] = struct{}{} - toolNames[callID] = toolName - } - if len(ids) == 0 { - return nil, nil - } - return ids, toolNames -} - -// toolParticipatesInMicroCompact 判断工具是否应参与 micro compact;未知工具默认视为可压缩。 -func toolParticipatesInMicroCompact(toolName string, policies MicroCompactPolicySource) bool { - if policies == nil { - return true - } - return policies.MicroCompactPolicy(toolName) != tools.MicroCompactPolicyPreserveHistory -} - -// compactableToolMessageContents 收集工具块中可压缩消息的渲染内容,跳过被钉住的结果。 -func compactableToolMessageContents(messages []providertypes.Message, span internalcompact.MessageSpan, compactableIDs map[string]struct{}, toolNames map[string]string, pinChecker MicroCompactPinChecker) map[int]string { - var contents map[int]string - for messageIndex := span.Start + 1; messageIndex < span.End; messageIndex++ { - content, ok := isCompactableToolMessage(messages[messageIndex], compactableIDs, toolNames, pinChecker) - if !ok { - continue - } - if contents == nil { - contents = make(map[int]string) - } - contents[messageIndex] = content - } - return contents -} - -// hasCompactableToolMessage 判断工具块中是否存在至少一条可压缩且未被钉住的工具消息。 -func hasCompactableToolMessage(messages []providertypes.Message, span internalcompact.MessageSpan, compactableIDs map[string]struct{}, toolNames map[string]string, pinChecker MicroCompactPinChecker) bool { - for messageIndex := span.Start + 1; messageIndex < span.End; messageIndex++ { - if _, ok := isCompactableToolMessage(messages[messageIndex], compactableIDs, toolNames, pinChecker); ok { - return true - } - } - return false -} - -// isCompactableToolMessage 判断工具消息是否可压缩(非保留策略且未被钉住),返回渲染内容和是否可压缩。 -func isCompactableToolMessage(message providertypes.Message, compactableIDs map[string]struct{}, toolNames map[string]string, pinChecker MicroCompactPinChecker) (string, bool) { - content, ok := compactableToolMessageContent(message, compactableIDs) - if !ok { - return "", false - } - callID := strings.TrimSpace(message.ToolCallID) - toolName := toolNameFromCallID(callID, toolNames) - if isPinnedToolMessage(toolName, message.ToolMetadata, pinChecker) { - return "", false - } - return content, true -} - -// compactableToolMessageContent 判断 tool 消息是否可压缩,并返回渲染后的内容文本。 -func compactableToolMessageContent(message providertypes.Message, compactableIDs map[string]struct{}) (string, bool) { - if message.Role != providertypes.RoleTool || message.IsError { - return "", false - } - callID := strings.TrimSpace(message.ToolCallID) - if _, ok := compactableIDs[callID]; !ok { - return "", false - } - - content := strings.TrimSpace(renderDisplayParts(message.Parts)) - if content == "" || content == microCompactClearedMessage { - return "", false - } - return content, true -} - -// summarizeOrClear 为单条可压缩工具消息生成摘要或回退到默认清除占位。 -func summarizeOrClear( - message providertypes.Message, - content string, - toolNames map[string]string, - summarizers MicroCompactSummarizerSource, -) string { - callID := strings.TrimSpace(message.ToolCallID) - toolName, ok := toolNames[callID] - if !ok { - return microCompactClearedMessage - } - - if summarizers != nil { - summarizer := summarizers.MicroCompactSummarizer(toolName) - if summarizer != nil { - summary := summarizer(content, message.ToolMetadata, message.IsError) - if summary != "" { - summary = sanitizeMicroCompactSummary(summary) - if summary != "" { - return summary - } - } - } - } - - summary := sanitizeMicroCompactSummary(fallbackSummary(toolName, content)) - if summary == "" { - return microCompactClearedMessage - } - return summary -} - -// fallbackSummary 为缺少专用摘要器的工具生成最小可读摘要,避免静默清空历史。 -func fallbackSummary(toolName string, content string) string { - trimmedName := strings.TrimSpace(toolName) - if trimmedName == "" { - return "" - } - - parts := []string{ - "[summary]", - trimmedName, - "lines=" + strconv.Itoa(stableLineCount(content)), - "chars=" + strconv.Itoa(utf8.RuneCountInString(content)), - } - return strings.Join(parts, " ") -} - -// stableLineCount 统计文本行数;空文本返回 0,末尾换行不会产生额外空行计数。 -func stableLineCount(text string) int { - if text == "" { - return 0 - } - count := strings.Count(text, "\n") + 1 - if strings.HasSuffix(text, "\n") { - count-- - } - if count < 0 { - return 0 - } - return count -} - -// sanitizeMicroCompactSummary 对 summarizer 输出做最终净化与限长,避免把不安全文本直接回灌上下文。 -func sanitizeMicroCompactSummary(summary string) string { - trimmed := strings.TrimSpace(summary) - if trimmed == "" { - return "" - } - - var b strings.Builder - b.Grow(len(trimmed)) - for _, r := range trimmed { - if r < 32 || r == 127 { - continue - } - b.WriteRune(r) - } - - clean := strings.TrimSpace(b.String()) - if clean == "" { - return "" - } - return truncateSummaryRunes(clean, microCompactSummaryMaxRunes) -} - -// truncateSummaryRunes 按 rune 数量截断摘要,超限时追加 "..."。 -func truncateSummaryRunes(summary string, maxRunes int) string { - if maxRunes <= 0 || summary == "" { - return summary - } - - runes := []rune(summary) - if len(runes) <= maxRunes { - return summary - } - return string(runes[:maxRunes]) + "..." -} diff --git a/internal/context/microcompact_summarizer_test.go b/internal/context/microcompact_summarizer_test.go deleted file mode 100644 index e1b010e8c..000000000 --- a/internal/context/microcompact_summarizer_test.go +++ /dev/null @@ -1,416 +0,0 @@ -package context - -import ( - "strings" - "testing" - "unicode/utf8" - - "neo-code/internal/context/internalcompact" - providertypes "neo-code/internal/provider/types" - "neo-code/internal/tools" -) - -// stubMicroCompactSummarizerSource 实现 MicroCompactSummarizerSource,用于测试。 -type stubMicroCompactSummarizerSource map[string]tools.ContentSummarizer - -func (s stubMicroCompactSummarizerSource) MicroCompactSummarizer(name string) tools.ContentSummarizer { - return s[name] -} - -// TestMicroCompactWithSummarizerProducesSummary 验证注册 summarizer 的工具生成摘要而非清除占位。 -func TestMicroCompactWithSummarizerProducesSummary(t *testing.T) { - t.Parallel() - - bashSummarizer := func(content string, metadata map[string]string, isError bool) string { - return "[summary] bash: " + content - } - - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old bash result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest bash result")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("current reply")}}, - } - - got := microCompactMessagesWithPolicies( - messages, - stubMicroCompactPolicySource{}, - 2, - stubMicroCompactSummarizerSource{"bash": bashSummarizer}, - nil, - ) - - if renderDisplayParts(got[2].Parts) == microCompactClearedMessage { - t.Fatalf("expected summarized content for old bash result, got cleared placeholder") - } - if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] bash:") { - t.Fatalf("expected summary prefix, got %q", renderDisplayParts(got[2].Parts)) - } - if renderDisplayParts(got[4].Parts) != "recent bash result" { - t.Fatalf("expected recent bash result retained, got %q", renderDisplayParts(got[4].Parts)) - } - if renderDisplayParts(got[6].Parts) != "latest bash result" { - t.Fatalf("expected latest bash result retained, got %q", renderDisplayParts(got[6].Parts)) - } - // 原始切片不被修改 - if renderDisplayParts(messages[2].Parts) != "old bash result" { - t.Fatalf("expected original slice unchanged, got %q", renderDisplayParts(messages[2].Parts)) - } -} - -// TestMicroCompactWithoutSummarizerFallsBackToSummary 验证未注册 summarizer 的工具使用通用兜底摘要。 -func TestMicroCompactWithoutSummarizerFallsBackToSummary(t *testing.T) { - t.Parallel() - - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old read result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest bash result")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - } - - // 只为 bash 注册 summarizer,read_file 没有 - got := microCompactMessagesWithPolicies( - messages, - stubMicroCompactPolicySource{}, - 2, - stubMicroCompactSummarizerSource{ - "bash": func(content string, metadata map[string]string, isError bool) string { - return "[summary] bash: " + content - }, - }, - nil, - ) - - summary := renderDisplayParts(got[2].Parts) - if summary == microCompactClearedMessage { - t.Fatalf("expected fallback summary for read_file without summarizer, got cleared placeholder") - } - if !strings.Contains(summary, "[summary] filesystem_read_file") { - t.Fatalf("expected fallback summary to include tool name, got %q", summary) - } -} - -// TestMicroCompactMixedSpanWithSummarizer 验证混合工具 span 中部分有摘要、部分清除。 -func TestMicroCompactMixedSpanWithSummarizer(t *testing.T) { - t.Parallel() - - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "bash", Arguments: "{}"}, - {ID: "call-2", Name: "filesystem_read_file", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("bash output")}}, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("read output")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-4", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-4", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest bash")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("reply")}}, - } - - got := microCompactMessagesWithPolicies( - messages, - stubMicroCompactPolicySource{}, - 2, - stubMicroCompactSummarizerSource{ - "bash": func(content string, metadata map[string]string, isError bool) string { - return "[summary] " + content - }, - }, - nil, - ) - - // call-1 bash 在旧 span,有 summarizer,应生成摘要 - if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary]") { - t.Fatalf("expected bash summary in old span, got %q", renderDisplayParts(got[2].Parts)) - } - summary := renderDisplayParts(got[3].Parts) - if summary == microCompactClearedMessage { - t.Fatalf("expected read_file fallback summary in old span, got cleared placeholder") - } - if !strings.Contains(summary, "[summary] filesystem_read_file") { - t.Fatalf("expected read_file fallback summary to include tool name, got %q", summary) - } -} - -// TestMicroCompactSummarizerReturnsEmptyFallsBackToSummary 验证 summarizer 返回空字符串时回退到通用摘要。 -func TestMicroCompactSummarizerReturnsEmptyFallsBackToSummary(t *testing.T) { - t.Parallel() - - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("middle result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent result")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - } - - got := microCompactMessagesWithPolicies( - messages, - stubMicroCompactPolicySource{}, - 2, - stubMicroCompactSummarizerSource{ - "bash": func(content string, metadata map[string]string, isError bool) string { - return "" // 返回空 - }, - }, - nil, - ) - - summary := renderDisplayParts(got[2].Parts) - if summary == microCompactClearedMessage { - t.Fatalf("expected fallback summary when summarizer returns empty, got cleared placeholder") - } - if !strings.Contains(summary, "[summary] bash") { - t.Fatalf("expected fallback summary to include tool name, got %q", summary) - } -} - -// TestSummarizeOrClearWithNilSummarizers 验证 nil summarizers 回退到清除。 -func TestSummarizeOrClearWithNilSummarizers(t *testing.T) { - t.Parallel() - - got := summarizeOrClear( - providertypes.Message{Parts: []providertypes.ContentPart{providertypes.NewTextPart("test")}}, - "test", - nil, - nil, - ) - if got != microCompactClearedMessage { - t.Fatalf("expected cleared message for nil summarizers, got %q", got) - } -} - -func TestSummarizeOrClearFallsBackWithoutRegisteredSummarizer(t *testing.T) { - t.Parallel() - - got := summarizeOrClear( - providertypes.Message{ToolCallID: "call-1"}, - "first line\nsecond line", - map[string]string{"call-1": "mcp.github.issue"}, - nil, - ) - if got == microCompactClearedMessage { - t.Fatalf("expected fallback summary for MCP tool, got cleared placeholder") - } - if !strings.Contains(got, "[summary] mcp.github.issue") { - t.Fatalf("expected MCP tool name in fallback summary, got %q", got) - } - if !strings.Contains(got, "lines=2") { - t.Fatalf("expected line count in fallback summary, got %q", got) - } -} - -// TestSummarizeOrClearWithToolNamesLookup 验证 toolNames map 查找工具名。 -func TestSummarizeOrClearWithToolNamesLookup(t *testing.T) { - t.Parallel() - - t.Run("found", func(t *testing.T) { - toolNames := map[string]string{"call-2": "filesystem_read_file"} - got := summarizeOrClear( - providertypes.Message{ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("content")}}, - "content", - toolNames, - stubMicroCompactSummarizerSource{ - "filesystem_read_file": func(content string, metadata map[string]string, isError bool) string { - return "[summary] " + content - }, - }, - ) - if !strings.Contains(got, "[summary]") { - t.Fatalf("expected summary, got %q", got) - } - }) - - t.Run("not_found_in_tool_names", func(t *testing.T) { - toolNames := map[string]string{"call-1": "bash"} - got := summarizeOrClear( - providertypes.Message{ToolCallID: "unknown-id", Parts: []providertypes.ContentPart{providertypes.NewTextPart("content")}}, - "content", - toolNames, - stubMicroCompactSummarizerSource{}, - ) - if got != microCompactClearedMessage { - t.Fatalf("expected cleared for unknown tool call id, got %q", got) - } - }) -} - -// TestSummarizeOrClearSanitizesSummary 验证摘要回灌前会执行控制字符净化与长度裁剪。 -func TestSummarizeOrClearSanitizesSummary(t *testing.T) { - t.Parallel() - - raw := strings.Repeat("x", microCompactSummaryMaxRunes+50) + "\n\t\x07" - got := summarizeOrClear( - providertypes.Message{ToolCallID: "call-1"}, - "ignored", - map[string]string{"call-1": "bash"}, - stubMicroCompactSummarizerSource{ - "bash": func(content string, metadata map[string]string, isError bool) string { - return raw - }, - }, - ) - - if strings.ContainsAny(got, "\n\t\a") { - t.Fatalf("expected control characters removed, got %q", got) - } - if utf8.RuneCountInString(got) > microCompactSummaryMaxRunes+3 { - t.Fatalf("expected summary capped, got %d runes", utf8.RuneCountInString(got)) - } - if !strings.HasSuffix(got, "...") { - t.Fatalf("expected truncated summary suffix, got %q", got) - } -} - -// TestSummarizeOrClearSanitizationEmptyFallback 验证净化后为空时会回退清理占位。 -func TestSummarizeOrClearSanitizationEmptyFallback(t *testing.T) { - t.Parallel() - - got := summarizeOrClear( - providertypes.Message{ToolCallID: "call-1"}, - "ignored", - map[string]string{"call-1": "bash"}, - stubMicroCompactSummarizerSource{ - "bash": func(content string, metadata map[string]string, isError bool) string { - return "\n\t\x07 " - }, - }, - ) - - if got == microCompactClearedMessage { - t.Fatalf("expected fallback summary when sanitized summary is empty, got cleared placeholder") - } - if !strings.Contains(got, "[summary] bash") { - t.Fatalf("expected fallback summary to include tool name, got %q", got) - } -} - -// TestIsToolCallSpanBoundaries 验证 span 边界异常时返回 false。 -func TestIsToolCallSpanBoundaries(t *testing.T) { - t.Parallel() - - messages := []providertypes.Message{ - {Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{{ID: "c1", Name: "bash"}}}, - } - - if isToolCallSpan(messages, internalcompact.MessageSpan{Start: -1, End: 0}) { - t.Fatal("expected false for negative start") - } - if isToolCallSpan(messages, internalcompact.MessageSpan{Start: 2, End: 3}) { - t.Fatal("expected false for out-of-range start") - } -} - -// TestCompactableToolCallIDsEmptyInput 验证空 tool call 输入时返回 nil。 -func TestCompactableToolCallIDsEmptyInput(t *testing.T) { - t.Parallel() - - ids, names := compactableToolCallIDs(nil, nil) - if ids != nil || names != nil { - t.Fatalf("expected nil maps for empty input, got ids=%v names=%v", ids, names) - } -} - -// TestHasCompactableToolMessage 验证工具块可压缩消息探测逻辑。 -func TestHasCompactableToolMessage(t *testing.T) { - t.Parallel() - - span := internalcompact.MessageSpan{Start: 0, End: 3} - ids := map[string]struct{}{"call-1": {}} - - t.Run("true_when_matching_tool_message_exists", func(t *testing.T) { - messages := []providertypes.Message{ - {Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{{ID: "call-1", Name: "bash"}}}, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("output")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("u")}}, - } - if !hasCompactableToolMessage(messages, span, ids, nil, nil) { - t.Fatal("expected compactable tool message to be found") - } - }) - - t.Run("false_when_tool_messages_are_not_compactable", func(t *testing.T) { - messages := []providertypes.Message{ - {Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{{ID: "call-1", Name: "bash"}}}, - {Role: providertypes.RoleTool, ToolCallID: "call-1", IsError: true, Parts: []providertypes.ContentPart{providertypes.NewTextPart("error")}}, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("other")}}, - } - if hasCompactableToolMessage(messages, span, ids, nil, nil) { - t.Fatal("expected no compactable tool message") - } - }) -} diff --git a/internal/context/microcompact_test.go b/internal/context/microcompact_test.go deleted file mode 100644 index 34f916673..000000000 --- a/internal/context/microcompact_test.go +++ /dev/null @@ -1,523 +0,0 @@ -package context - -import ( - "strings" - "testing" - - providertypes "neo-code/internal/provider/types" - "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() - - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old read result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: "webfetch", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("current working reply")}}, - } - - got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 2, nil, nil) - if len(got) != len(messages) { - t.Fatalf("expected message count to stay unchanged, got %d want %d", len(got), len(messages)) - } - if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] filesystem_read_file") { - t.Fatalf("expected oldest compactable tool result to fall back to summary, got %q", renderDisplayParts(got[2].Parts)) - } - if renderDisplayParts(got[4].Parts) != "recent bash result" { - t.Fatalf("expected recent compactable tool result to be retained, got %q", renderDisplayParts(got[4].Parts)) - } - if renderDisplayParts(got[6].Parts) != "latest webfetch result" { - t.Fatalf("expected latest compactable tool result to be retained, got %q", renderDisplayParts(got[6].Parts)) - } - if renderDisplayParts(messages[2].Parts) != "old read result" { - t.Fatalf("expected original slice to remain unchanged, got %q", renderDisplayParts(messages[2].Parts)) - } -} - -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 := []providertypes.Message{ - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "", Name: "bash", Arguments: "{}"}, - }, - }, - } - got := microCompactMessagesWithPolicies(assistantOnly, stubMicroCompactPolicySource{}, 0, nil, nil) - if len(got) != 1 || len(got[0].ToolCalls) != 1 { - t.Fatalf("expected invalid tool call id path to keep message untouched, got %+v", got) - } -} - -func TestMicroCompactMessagesKeepsProtectedTailUntouched(t *testing.T) { - t.Parallel() - - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-0", Name: "filesystem_grep", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-0", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old grep result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent read result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("tail bash result")}}, - } - - got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 2, nil, nil) - if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] filesystem_grep") { - t.Fatalf("expected old tool result before protected tail to fall back to summary, got %q", renderDisplayParts(got[2].Parts)) - } - if renderDisplayParts(got[4].Parts) != "recent read result" { - t.Fatalf("expected recent tool result before protected tail to remain, got %q", renderDisplayParts(got[4].Parts)) - } - if renderDisplayParts(got[6].Parts) != "recent bash result" { - t.Fatalf("expected second recent tool result before protected tail to remain, got %q", renderDisplayParts(got[6].Parts)) - } - if renderDisplayParts(got[9].Parts) != "tail bash result" { - t.Fatalf("expected protected tail tool result to remain, got %q", renderDisplayParts(got[9].Parts)) - } -} - -func TestMicroCompactMessagesKeepsPreservedToolsErrorsAndOrphans(t *testing.T) { - t.Parallel() - - messages := []providertypes.Message{ - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "custom_tool", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("custom result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: "filesystem_edit", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("edit failed")}, IsError: true}, - {Role: providertypes.RoleTool, ToolCallID: "orphan", Parts: []providertypes.ContentPart{providertypes.NewTextPart("orphan result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: "filesystem_write_file", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart(microCompactClearedMessage)}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-4", Name: "filesystem_grep", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-4", Parts: []providertypes.ContentPart{providertypes.NewTextPart("")}}, - } - - got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ - "custom_tool": tools.MicroCompactPolicyPreserveHistory, - }, 2, nil, nil) - if renderDisplayParts(got[1].Parts) != "custom result" { - t.Fatalf("expected preserved tool result to remain, got %q", renderDisplayParts(got[1].Parts)) - } - if renderDisplayParts(got[3].Parts) != "edit failed" { - t.Fatalf("expected error tool result to remain, got %q", renderDisplayParts(got[3].Parts)) - } - if renderDisplayParts(got[4].Parts) != "orphan result" { - t.Fatalf("expected orphan tool result to remain, got %q", renderDisplayParts(got[4].Parts)) - } - if renderDisplayParts(got[6].Parts) != microCompactClearedMessage { - t.Fatalf("expected already cleared content to remain unchanged, got %q", renderDisplayParts(got[6].Parts)) - } - if renderDisplayParts(got[8].Parts) != "" { - t.Fatalf("expected empty tool result to remain empty, got %q", renderDisplayParts(got[8].Parts)) - } -} - -func TestMicroCompactMessagesClearsOnlyNonPreservedResultsInMixedToolSpan(t *testing.T) { - t.Parallel() - - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, - {ID: "call-2", Name: "custom_tool", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("read result")}}, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("custom result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-4", Name: "webfetch", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-4", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("current reply")}}, - } - - got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ - "custom_tool": tools.MicroCompactPolicyPreserveHistory, - }, 2, nil, nil) - if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] filesystem_read_file") { - t.Fatalf("expected default compactable tool result to fall back to summary, got %q", renderDisplayParts(got[2].Parts)) - } - if renderDisplayParts(got[3].Parts) != "custom result" { - t.Fatalf("expected preserved tool result in mixed span to remain, got %q", renderDisplayParts(got[3].Parts)) - } - 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 := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "repo_search", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("old repo search result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: "webfetch", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - } - - got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 2, nil, nil) - if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] repo_search") { - t.Fatalf("expected new tool result to be compacted into fallback summary by default, got %q", renderDisplayParts(got[2].Parts)) - } -} - -func TestMicroCompactMessagesPreservesSpawnSubAgentHistory(t *testing.T) { - t.Parallel() - - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: tools.ToolNameSpawnSubAgent, Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("spawned analysis")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: tools.ToolNameBash, Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: tools.ToolNameWebFetch, Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - } - - got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ - tools.ToolNameSpawnSubAgent: tools.MicroCompactPolicyPreserveHistory, - }, 1, nil, nil) - if renderDisplayParts(got[2].Parts) != "spawned analysis" { - t.Fatalf("expected spawn_subagent history to be preserved, got %q", renderDisplayParts(got[2].Parts)) - } -} - -func TestMicroCompactMessagesPreservesCodebaseReadHistory(t *testing.T) { - t.Parallel() - - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: tools.ToolNameCodebaseRead, Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("path: main.go\n\npackage main")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: tools.ToolNameBash, Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: tools.ToolNameWebFetch, Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - } - - got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ - tools.ToolNameCodebaseRead: tools.MicroCompactPolicyPreserveHistory, - }, 2, nil, nil) - if renderDisplayParts(got[2].Parts) != "path: main.go\n\npackage main" { - t.Fatalf("expected codebase_read history to stay visible, got %q", renderDisplayParts(got[2].Parts)) - } -} - -func TestMicroCompactMessagesSkipsEmptyRecentSpansWhenCountingRetainedBudget(t *testing.T) { - t.Parallel() - - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("older read result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: "filesystem_grep", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("middle grep result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: "filesystem_edit", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("near edit result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-4", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-4", Parts: []providertypes.ContentPart{providertypes.NewTextPart("")}, IsError: true}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-5", Name: "webfetch", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-5", Parts: []providertypes.ContentPart{providertypes.NewTextPart("")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("current reply")}}, - } - - got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 2, nil, nil) - if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] filesystem_read_file") { - t.Fatalf("expected oldest valid tool result to fall back to summary, got %q", renderDisplayParts(got[2].Parts)) - } - if renderDisplayParts(got[4].Parts) != "middle grep result" { - t.Fatalf("expected middle valid tool result to remain, got %q", renderDisplayParts(got[4].Parts)) - } - if renderDisplayParts(got[6].Parts) != "near edit result" { - t.Fatalf("expected nearer valid tool result to remain, got %q", renderDisplayParts(got[6].Parts)) - } - if renderDisplayParts(got[8].Parts) != "" { - t.Fatalf("expected error/empty tool result to remain unchanged, got %q", renderDisplayParts(got[8].Parts)) - } - if renderDisplayParts(got[10].Parts) != "" { - t.Fatalf("expected empty recent tool result to remain unchanged, got %q", renderDisplayParts(got[10].Parts)) - } -} - -func TestMicroCompactMessagesSkipsToolMessagesWhenCompactableIDsMissing(t *testing.T) { - t.Parallel() - - messages := []providertypes.Message{ - {Role: providertypes.RoleTool, ToolCallID: "orphan", Parts: []providertypes.ContentPart{providertypes.NewTextPart("orphan result")}}, - } - - got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 0, nil, nil) - if renderDisplayParts(got[0].Parts) != "orphan result" { - t.Fatalf("expected orphan tool result to remain, got %q", renderDisplayParts(got[0].Parts)) - } -} - -// TestMicroCompactPinnedResultNotCompacted 验证被 pin checker 钉住的工具结果不会被压缩。 -func TestMicroCompactPinnedResultNotCompacted(t *testing.T) { - t.Parallel() - - stubPin := stubMicroCompactPinChecker{ - "filesystem_write_file": map[string]bool{"README.md": true}, - } - - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "filesystem_write_file", Arguments: `{"path":"README.md"}`}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("README content")}, ToolMetadata: map[string]string{"path": "/project/README.md"}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest bash result")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("current reply")}}, - } - - got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 1, nil, stubPin) - if renderDisplayParts(got[2].Parts) != "README content" { - t.Fatalf("expected pinned README result to be preserved, got %q", renderDisplayParts(got[2].Parts)) - } -} - -// TestMicroCompactMixedPinnedAndNonPinned 验证同一 span 中钉住和非钉住结果混合时仅压缩非钉住的。 -func TestMicroCompactMixedPinnedAndNonPinned(t *testing.T) { - t.Parallel() - - stubPin := stubMicroCompactPinChecker{ - "filesystem_write_file": map[string]bool{"README.md": true}, - } - - messages := []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "filesystem_write_file", Arguments: `{"path":"README.md"}`}, - {ID: "call-2", Name: "filesystem_write_file", Arguments: `{"path":"main.go"}`}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("README content")}, ToolMetadata: map[string]string{"path": "/project/README.md"}}, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("main.go content")}, ToolMetadata: map[string]string{"path": "/project/main.go"}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, - {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("reply")}}, - } - - got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 1, nil, stubPin) - if renderDisplayParts(got[2].Parts) != "README content" { - t.Fatalf("expected pinned README result preserved, got %q", renderDisplayParts(got[2].Parts)) - } - if !strings.Contains(renderDisplayParts(got[3].Parts), "[summary] filesystem_write_file") { - t.Fatalf("expected non-pinned main.go result to fall back to summary, got %q", renderDisplayParts(got[3].Parts)) - } -} - -// stubMicroCompactPinChecker 实现 MicroCompactPinChecker,用于测试。 -type stubMicroCompactPinChecker map[string]map[string]bool - -func (s stubMicroCompactPinChecker) ShouldPin(toolName string, metadata map[string]string) bool { - paths, ok := s[toolName] - if !ok { - return false - } - path := metadata["path"] - if path == "" { - path = metadata["relative_path"] - } - for pinnedPath, shouldPin := range paths { - if shouldPin && strings.Contains(path, pinnedPath) { - return true - } - } - return false -} diff --git a/internal/context/pin_checker.go b/internal/context/pin_checker.go deleted file mode 100644 index 3adf3860f..000000000 --- a/internal/context/pin_checker.go +++ /dev/null @@ -1,92 +0,0 @@ -package context - -import ( - "path/filepath" - "strings" - - "neo-code/internal/tools" -) - -// defaultPinPatterns 列出关键产物文件的 basename glob 模式,匹配的工具结果不参与微压缩。 -var defaultPinPatterns = []string{ - "README*", - "*.spec.*", - "*.schema.*", - "docker-compose*", - "*migration*", - "Makefile", - "go.mod", - "package.json", -} - -// defaultPinToolNames 约束默认 pin 仅对明确修改文件内容的工具生效,避免扩散到读取类或自定义工具。 -var defaultPinToolNames = map[string]struct{}{ - tools.ToolNameFilesystemWriteFile: {}, - tools.ToolNameFilesystemEdit: {}, -} - -// pinChecker 基于文件路径 glob 模式判断工具结果是否应钉住。 -type pinChecker struct { - patterns []string -} - -// NewDefaultPinChecker 返回使用默认钉住模式的 PinChecker。 -func NewDefaultPinChecker() MicroCompactPinChecker { - return &pinChecker{patterns: defaultPinPatterns} -} - -// ShouldPin 判断工具结果是否应钉住:从 metadata 中提取文件路径,对 basename 匹配 glob 模式。 -func (p *pinChecker) ShouldPin(toolName string, metadata map[string]string) bool { - if len(metadata) == 0 { - return false - } - if !toolSupportsPinnedRetention(toolName) { - return false - } - - for _, path := range candidatePinPaths(metadata) { - base := filepath.Base(path) - for _, pattern := range p.patterns { - if matched, _ := filepath.Match(pattern, base); matched { - return true - } - } - } - return false -} - -// candidatePinPaths 按稳定顺序提取可参与 pin 判断的文件路径字段。 -func candidatePinPaths(metadata map[string]string) []string { - keys := []string{"relative_path", "path", "source_path", "destination_path"} - paths := make([]string, 0, len(keys)) - for _, key := range keys { - path := strings.TrimSpace(metadata[key]) - if path == "" { - continue - } - paths = append(paths, path) - } - if len(paths) == 0 { - return nil - } - return paths -} - -// toolSupportsPinnedRetention 判断工具是否允许参与默认 pin 策略,避免非文件修改类工具扩大保留范围。 -func toolSupportsPinnedRetention(toolName string) bool { - _, ok := defaultPinToolNames[strings.TrimSpace(toolName)] - return ok -} - -// isPinnedToolMessage 检查工具消息是否被 pin checker 钉住,被钉住的消息不参与微压缩。 -func isPinnedToolMessage(toolName string, metadata map[string]string, checker MicroCompactPinChecker) bool { - if checker == nil || len(metadata) == 0 { - return false - } - return checker.ShouldPin(toolName, metadata) -} - -// toolNameFromCallID 在 toolNames 映射中查找 callID 对应的工具名。 -func toolNameFromCallID(callID string, toolNames map[string]string) string { - return toolNames[strings.TrimSpace(callID)] -} diff --git a/internal/context/pin_checker_test.go b/internal/context/pin_checker_test.go deleted file mode 100644 index f4d9a34ff..000000000 --- a/internal/context/pin_checker_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package context - -import ( - "testing" -) - -func TestDefaultPinCheckerMatchesKeyArtifacts(t *testing.T) { - t.Parallel() - - checker := NewDefaultPinChecker() - - tests := []struct { - toolName string - path string - expected bool - }{ - {toolName: "filesystem_write_file", path: "README.md", expected: true}, - {toolName: "filesystem_write_file", path: "README.txt", expected: true}, - {toolName: "filesystem_write_file", path: "readme.md", expected: false}, // glob 区分大小写 - {toolName: "filesystem_write_file", path: "api.spec.yaml", expected: true}, - {toolName: "filesystem_write_file", path: "design.spec.md", expected: true}, - {toolName: "filesystem_write_file", path: "db.schema.json", expected: true}, - {toolName: "filesystem_write_file", path: "schema.sql", expected: false}, // *schema.* 需要两端有内容 - {toolName: "filesystem_write_file", path: "db.schema.sql", expected: true}, - {toolName: "filesystem_write_file", path: "docker-compose.yml", expected: true}, - {toolName: "filesystem_write_file", path: "docker-compose.yaml", expected: true}, - {toolName: "filesystem_write_file", path: ".env", expected: false}, - {toolName: "filesystem_write_file", path: ".env.local", expected: false}, - {toolName: "filesystem_write_file", path: ".env.example", expected: false}, - {toolName: "filesystem_write_file", path: "01_migration.sql", expected: true}, - {toolName: "filesystem_write_file", path: "migration.rb", expected: true}, - {toolName: "filesystem_write_file", path: "create_users_migration.sql", expected: true}, - {toolName: "filesystem_write_file", path: "Makefile", expected: true}, - {toolName: "filesystem_write_file", path: "go.mod", expected: true}, - {toolName: "filesystem_write_file", path: "package.json", expected: true}, - {toolName: "filesystem_write_file", path: "main.go", expected: false}, - {toolName: "filesystem_write_file", path: "app.tsx", expected: false}, - {toolName: "filesystem_write_file", path: "index.js", expected: false}, - {toolName: "filesystem_write_file", path: "utils.py", expected: false}, - {toolName: "filesystem_write_file", path: "style.css", expected: false}, - {toolName: "filesystem_edit", path: "README.md", expected: true}, - {toolName: "filesystem_read_file", path: "README.md", expected: false}, - {toolName: "bash", path: "README.md", expected: false}, - } - - for _, tt := range tests { - got := checker.ShouldPin(tt.toolName, map[string]string{"path": "/project/" + tt.path}) - if got != tt.expected { - t.Errorf("ShouldPin(tool=%q, path=%q) = %v, want %v", tt.toolName, tt.path, got, tt.expected) - } - } -} - -func TestDefaultPinCheckerUsesRelativePath(t *testing.T) { - t.Parallel() - - checker := NewDefaultPinChecker() - - // relative_path 优先于 path - got := checker.ShouldPin("filesystem_write_file", map[string]string{ - "relative_path": "api.spec.yaml", - }) - if !got { - t.Error("expected relative_path match for api.spec.yaml") - } -} - -func TestDefaultPinCheckerFallsBackToPath(t *testing.T) { - t.Parallel() - - checker := NewDefaultPinChecker() - - got := checker.ShouldPin("filesystem_write_file", map[string]string{ - "path": "/project/README.md", - }) - if !got { - t.Error("expected path fallback match for README.md") - } -} - -func TestDefaultPinCheckerNoPathReturnsFalse(t *testing.T) { - t.Parallel() - - checker := NewDefaultPinChecker() - - got := checker.ShouldPin("filesystem_write_file", map[string]string{"workdir": "/tmp"}) - if got { - t.Error("expected false when no path in metadata") - } -} - -func TestDefaultPinCheckerEmptyMetadataReturnsFalse(t *testing.T) { - t.Parallel() - - checker := NewDefaultPinChecker() - - got := checker.ShouldPin("filesystem_write_file", nil) - if got { - t.Error("expected false for nil metadata") - } -} - -func TestDefaultPinCheckerBashToolNotPinned(t *testing.T) { - t.Parallel() - - checker := NewDefaultPinChecker() - - // bash 工具元信息只有 workdir,不应被钉住 - got := checker.ShouldPin("bash", map[string]string{"workdir": "/project"}) - if got { - t.Error("expected bash tool with workdir only to not be pinned") - } -} - -func TestDefaultPinCheckerIgnoresPathMetadataForUnsupportedTool(t *testing.T) { - t.Parallel() - - checker := NewDefaultPinChecker() - - got := checker.ShouldPin("filesystem_read_file", map[string]string{ - "path": "/project/README.md", - }) - if got { - t.Error("expected unsupported tool with path metadata to not be pinned") - } -} diff --git a/internal/context/projection.go b/internal/context/projection.go index 9db200dfa..849a2a7ca 100644 --- a/internal/context/projection.go +++ b/internal/context/projection.go @@ -16,6 +16,32 @@ const ( const truncatedExcerptMarker = "\n...[truncated]...\n" +// cloneContextMessages 深拷贝消息切片,避免读时投影污染 runtime 持有的原始会话消息。 +func cloneContextMessages(messages []providertypes.Message) []providertypes.Message { + if len(messages) == 0 { + return nil + } + + cloned := make([]providertypes.Message, 0, len(messages)) + for _, message := range messages { + cloned = append(cloned, cloneSingleMessage(message)) + } + return cloned +} + +// cloneSingleMessage 深拷贝单条消息,隔离 ToolCalls 和 ToolMetadata 的底层引用。 +func cloneSingleMessage(msg providertypes.Message) providertypes.Message { + next := msg + next.ToolCalls = append([]providertypes.ToolCall(nil), msg.ToolCalls...) + if len(msg.ToolMetadata) > 0 { + next.ToolMetadata = make(map[string]string, len(msg.ToolMetadata)) + for key, value := range msg.ToolMetadata { + next.ToolMetadata[key] = value + } + } + return next +} + // ProjectToolMessagesForModel 原地投影 tool 消息,复用主链路对模型可见的只读格式化规则。 func ProjectToolMessagesForModel(messages []providertypes.Message) []providertypes.Message { for i := range messages { @@ -184,9 +210,6 @@ func isInjectableToolMessage(message providertypes.Message) bool { return false } content := strings.TrimSpace(renderDisplayParts(message.Parts)) - if content == microCompactClearedMessage { - return false - } return content != "" || len(message.ToolMetadata) > 0 } diff --git a/internal/context/projection_test.go b/internal/context/projection_test.go index 8e78a870b..387fe3ade 100644 --- a/internal/context/projection_test.go +++ b/internal/context/projection_test.go @@ -24,12 +24,6 @@ func TestProjectToolMessagesForModelSkipsMessagesThatCannotBeProjected(t *testin Parts: []providertypes.ContentPart{providertypes.NewTextPart(" ")}, ToolMetadata: map[string]string{"tool_name": "bash"}, }, - { - Role: providertypes.RoleTool, - ToolCallID: "call-3", - Parts: []providertypes.ContentPart{providertypes.NewTextPart(microCompactClearedMessage)}, - ToolMetadata: map[string]string{"tool_name": "bash"}, - }, { Role: providertypes.RoleTool, ToolCallID: "call-4", @@ -48,10 +42,7 @@ func TestProjectToolMessagesForModelSkipsMessagesThatCannotBeProjected(t *testin if !strings.Contains(renderDisplayParts(projected[2].Parts), "tool result") || projected[2].ToolMetadata != nil { t.Fatalf("metadata-only tool message should be projected, got %+v", projected[2]) } - if renderDisplayParts(projected[3].Parts) != microCompactClearedMessage || projected[3].ToolMetadata == nil { - t.Fatalf("cleared tool content should not be projected, got %+v", projected[3]) - } - if !strings.Contains(renderDisplayParts(projected[4].Parts), "tool result") || projected[4].ToolMetadata != nil { + if !strings.Contains(renderDisplayParts(projected[3].Parts), "tool result") || projected[3].ToolMetadata != nil { t.Fatalf("valid tool message should be projected, got %+v", projected[4]) } } @@ -370,11 +361,6 @@ func TestIsInjectableToolMessage(t *testing.T) { message: providertypes.Message{Role: providertypes.RoleTool, Parts: []providertypes.ContentPart{providertypes.NewTextPart(" ")}, ToolMetadata: map[string]string{"tool_name": "bash"}}, want: true, }, - { - name: "cleared", - message: providertypes.Message{Role: providertypes.RoleTool, Parts: []providertypes.ContentPart{providertypes.NewTextPart(microCompactClearedMessage)}}, - want: false, - }, { name: "valid", message: providertypes.Message{Role: providertypes.RoleTool, Parts: []providertypes.ContentPart{providertypes.NewTextPart("ok")}}, diff --git a/internal/context/types.go b/internal/context/types.go index a0296d266..9a6ac3fa5 100644 --- a/internal/context/types.go +++ b/internal/context/types.go @@ -7,7 +7,6 @@ import ( "neo-code/internal/repository" agentsession "neo-code/internal/session" "neo-code/internal/skills" - "neo-code/internal/tools" ) // Builder builds the provider-facing context for a single model round. @@ -73,32 +72,11 @@ type RepositoryRetrievalSection struct { Query string } -// MicroCompactPolicySource 定义 context 读取工具 micro compact 策略的最小依赖。 -type MicroCompactPolicySource interface { - MicroCompactPolicy(name string) tools.MicroCompactPolicy -} -// MicroCompactSummarizerSource 定义 context 查找按工具内容摘要器的最小依赖。 -type MicroCompactSummarizerSource interface { - MicroCompactSummarizer(name string) tools.ContentSummarizer -} -// MicroCompactPinChecker 定义上下文层判断单个工具结果是否应钉住(不参与微压缩)的接口。 -type MicroCompactPinChecker interface { - ShouldPin(toolName string, metadata map[string]string) bool -} -// MicroCompactConfig 聚合微压缩所需的三个依赖源,简化 Builder 构造参数。 -// 三个子接口仍各自遵循接口隔离原则;MicroCompactConfig 仅作为构造时的参数打包容器。 -type MicroCompactConfig struct { - Policies MicroCompactPolicySource - Summarizers MicroCompactSummarizerSource - PinChecker MicroCompactPinChecker -} -// CompactOptions controls read-time compact behavior inside the context builder. +// CompactOptions controls read-time context behavior inside the context builder. type CompactOptions struct { - DisableMicroCompact bool - MicroCompactRetainedToolSpans int - ReadTimeMaxMessageSpans int + ReadTimeMaxMessageSpans int } diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index d96bfe748..8d14201cb 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -30,14 +30,6 @@ func (m *runnerManagerAdapter) ListAvailableSpecs(context.Context, tools.SpecLis return nil, nil } -func (m *runnerManagerAdapter) MicroCompactPolicy(string) tools.MicroCompactPolicy { - return tools.MicroCompactPolicyCompact -} - -func (m *runnerManagerAdapter) MicroCompactSummarizer(string) tools.ContentSummarizer { - return nil -} - func (m *runnerManagerAdapter) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { if m.executeFn != nil { return m.executeFn(ctx, input) diff --git a/internal/runtime/run.go b/internal/runtime/run.go index 338d07ebe..8ae97b40b 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -571,8 +571,6 @@ func (s *Service) prepareTurnBudgetSnapshot(ctx context.Context, state *runState SessionOutputTokens: state.session.TokenOutputTotal, }, Compact: agentcontext.CompactOptions{ - DisableMicroCompact: cfg.Context.Compact.MicroCompactDisabled, - MicroCompactRetainedToolSpans: cfg.Context.Compact.MicroCompactRetainedToolSpans, ReadTimeMaxMessageSpans: cfg.Context.Compact.ReadTimeMaxMessageSpans, }, }) diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 326fd415f..3571e67d3 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -259,12 +259,8 @@ func NewWithFactory( toolManager = tools.NewRegistry() } if contextBuilder == nil { - contextBuilder = agentcontext.NewConfiguredBuilder(agentcontext.MicroCompactConfig{ - Policies: toolManager, - Summarizers: toolManager, - }) - } - + contextBuilder = agentcontext.NewConfiguredBuilder() + } service := &Service{ configManager: configManager, sessionStore: sessionStore, diff --git a/internal/runtime/runtime_remaining_branches_test.go b/internal/runtime/runtime_remaining_branches_test.go index ec084c1d1..2641f333d 100644 --- a/internal/runtime/runtime_remaining_branches_test.go +++ b/internal/runtime/runtime_remaining_branches_test.go @@ -32,13 +32,7 @@ func (m *callbackToolManager) ListAvailableSpecs(ctx context.Context, input tool return nil, ctx.Err() } -func (m *callbackToolManager) MicroCompactPolicy(name string) tools.MicroCompactPolicy { - return tools.MicroCompactPolicyCompact -} -func (m *callbackToolManager) MicroCompactSummarizer(name string) tools.ContentSummarizer { - return nil -} func (m *callbackToolManager) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { if m.executeFn != nil { diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 87de3bae8..2a0c176b1 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -782,7 +782,6 @@ 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) @@ -800,9 +799,6 @@ 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++ @@ -850,7 +846,6 @@ type stubToolManager struct { result tools.ToolResult err error listErr error - policies map[string]tools.MicroCompactPolicy listCalls int executeCalls int lastInput tools.ToolCallInput @@ -876,18 +871,7 @@ func (m *stubToolManager) ListAvailableSpecs(ctx context.Context, input tools.Sp return append([]providertypes.ToolSpec(nil), m.specs...), nil } -func (m *stubToolManager) MicroCompactPolicy(name string) tools.MicroCompactPolicy { - m.mu.Lock() - defer m.mu.Unlock() - if policy, ok := m.policies[name]; ok { - return policy - } - return tools.MicroCompactPolicyCompact -} -func (m *stubToolManager) MicroCompactSummarizer(name string) tools.ContentSummarizer { - return nil -} func (m *stubToolManager) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { m.mu.Lock() @@ -1622,9 +1606,6 @@ 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 builder.lastInput.TaskState.Goal != "Finish task state rollout" { t.Fatalf("expected session task state to be forwarded to builder, got %+v", builder.lastInput.TaskState) } @@ -1716,46 +1697,6 @@ func TestServiceRunUsesSessionSelectionForMetadataAndBudget(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([]providertypes.Message(nil), input.Messages...), - }, nil - }, - } - - scripted := &scriptedProvider{ - responses: []scriptedResponse{{ - Message: providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("done")}}, - FinishReason: "stop", - }}, - } - - service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, builder) - if err := service.Run(context.Background(), UserInput{RunID: "run-disable-micro-compact", Parts: []providertypes.ContentPart{providertypes.NewTextPart("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() @@ -1786,130 +1727,7 @@ 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 := agentsession.New("preserve history") - session.ID = "session-preserve-history" - session.Messages = []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "preserve_tool", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("preserved result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: "webfetch", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, - } - store.sessions[session.ID] = cloneSession(session) - - scripted := &scriptedProvider{ - responses: []scriptedResponse{{ - Message: providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("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", - Parts: []providertypes.ContentPart{providertypes.NewTextPart("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 := renderPartsForTest(scripted.requests[0].Messages[2].Parts); got != "preserved result" { - t.Fatalf("expected preserved tool result to remain visible, got %q", got) - } -} - -func TestServiceRunDefaultBuilderUsesGenericToolManagerMicroCompactPolicies(t *testing.T) { - t.Parallel() - manager := newRuntimeConfigManager(t) - store := newMemoryStore() - toolManager := &stubToolManager{ - policies: map[string]tools.MicroCompactPolicy{ - "preserve_tool": tools.MicroCompactPolicyPreserveHistory, - }, - } - - session := agentsession.New("preserve history by manager") - session.ID = "session-preserve-history-manager" - session.Messages = []providertypes.Message{ - {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-1", Name: "preserve_tool", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("preserved result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-2", Name: "bash", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, - { - Role: providertypes.RoleAssistant, - ToolCalls: []providertypes.ToolCall{ - {ID: "call-3", Name: "webfetch", Arguments: "{}"}, - }, - }, - {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, - } - store.sessions[session.ID] = cloneSession(session) - - scripted := &scriptedProvider{ - responses: []scriptedResponse{{ - Message: providertypes.Message{Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("done")}}, - FinishReason: "stop", - }}, - } - - service := NewWithFactory(manager, toolManager, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) - if err := service.Run(context.Background(), UserInput{ - SessionID: session.ID, - RunID: "run-preserve-history-generic-manager", - Parts: []providertypes.ContentPart{providertypes.NewTextPart("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 := renderPartsForTest(scripted.requests[0].Messages[2].Parts); 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/ask_user_tool.go b/internal/tools/ask_user_tool.go index 96a20e8bc..d1a537cb1 100644 --- a/internal/tools/ask_user_tool.go +++ b/internal/tools/ask_user_tool.go @@ -130,10 +130,6 @@ func (t *askUserTool) Schema() map[string]any { } } -func (t *askUserTool) MicroCompactPolicy() MicroCompactPolicy { - return MicroCompactPolicyPreserveHistory -} - func (t *askUserTool) Execute(ctx context.Context, call ToolCallInput) (ToolResult, error) { if t.broker == nil { return NewErrorResult(ToolNameAskUser, "ask_user broker not available", "ask_user broker is nil", nil), fmt.Errorf("tools: ask_user broker is nil") diff --git a/internal/tools/ask_user_tool_test.go b/internal/tools/ask_user_tool_test.go index 6f003cc8b..427a28cca 100644 --- a/internal/tools/ask_user_tool_test.go +++ b/internal/tools/ask_user_tool_test.go @@ -37,9 +37,6 @@ func TestNewAskUserToolDefaults(t *testing.T) { if _, ok := schema["properties"]; !ok { t.Fatalf("expected schema with properties") } - if tool.MicroCompactPolicy() != MicroCompactPolicyPreserveHistory { - t.Fatalf("expected PreserveHistory policy, got %v", tool.MicroCompactPolicy()) - } } func TestAskUserToolSchemaHasRequiredFields(t *testing.T) { diff --git a/internal/tools/bash/tool.go b/internal/tools/bash/tool.go index 92cf5c0c9..4a10c4a3a 100644 --- a/internal/tools/bash/tool.go +++ b/internal/tools/bash/tool.go @@ -1,13 +1,13 @@ package bash import ( + "neo-code/internal/tools" "context" "encoding/json" "errors" "strings" "time" - "neo-code/internal/tools" ) type Tool struct { @@ -80,11 +80,6 @@ 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/codebase/read.go b/internal/tools/codebase/read.go index f76476e13..427347fd8 100644 --- a/internal/tools/codebase/read.go +++ b/internal/tools/codebase/read.go @@ -1,12 +1,12 @@ package codebase import ( + "neo-code/internal/tools" "context" "encoding/json" "strings" "neo-code/internal/repository" - "neo-code/internal/tools" ) // ReadTool implements the codebase_read tool. @@ -49,10 +49,6 @@ func (t *ReadTool) Schema() map[string]any { } } -func (t *ReadTool) MicroCompactPolicy() tools.MicroCompactPolicy { - return tools.MicroCompactPolicyPreserveHistory -} - func (t *ReadTool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) { var in struct { Path string `json:"path"` diff --git a/internal/tools/codebase/read_test.go b/internal/tools/codebase/read_test.go index ba29f03b3..5fefb0ab6 100644 --- a/internal/tools/codebase/read_test.go +++ b/internal/tools/codebase/read_test.go @@ -32,9 +32,6 @@ func TestReadToolMetadata(t *testing.T) { if _, hasPath := props["path"]; !hasPath { t.Fatalf("Schema should have path property") } - if tool.MicroCompactPolicy() != tools.MicroCompactPolicyPreserveHistory { - t.Fatalf("MicroCompactPolicy() = %v, want PreserveHistory", tool.MicroCompactPolicy()) - } } func TestReadToolInvalidJSON(t *testing.T) { diff --git a/internal/tools/codebase/searchsymbol.go b/internal/tools/codebase/searchsymbol.go index e5d9286eb..11cd8a16a 100644 --- a/internal/tools/codebase/searchsymbol.go +++ b/internal/tools/codebase/searchsymbol.go @@ -1,12 +1,12 @@ package codebase import ( + "neo-code/internal/tools" "context" "encoding/json" "strings" "neo-code/internal/repository" - "neo-code/internal/tools" ) // SearchSymbolTool implements the codebase_search_symbol tool. @@ -53,10 +53,6 @@ func (t *SearchSymbolTool) Schema() map[string]any { } } -func (t *SearchSymbolTool) MicroCompactPolicy() tools.MicroCompactPolicy { - return tools.MicroCompactPolicyCompact -} - func (t *SearchSymbolTool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) { var in struct { Symbol string `json:"symbol"` diff --git a/internal/tools/codebase/searchsymbol_test.go b/internal/tools/codebase/searchsymbol_test.go index 33f792be3..cab6f50db 100644 --- a/internal/tools/codebase/searchsymbol_test.go +++ b/internal/tools/codebase/searchsymbol_test.go @@ -32,9 +32,6 @@ func TestSearchSymbolToolMetadata(t *testing.T) { if _, hasSymbol := props["symbol"]; !hasSymbol { t.Fatalf("Schema should have symbol property") } - if tool.MicroCompactPolicy() != tools.MicroCompactPolicyCompact { - t.Fatalf("MicroCompactPolicy() = %v, want Compact", tool.MicroCompactPolicy()) - } } func TestSearchSymbolToolInvalidJSON(t *testing.T) { diff --git a/internal/tools/codebase/searchtext.go b/internal/tools/codebase/searchtext.go index f5f670c7d..a0129b02b 100644 --- a/internal/tools/codebase/searchtext.go +++ b/internal/tools/codebase/searchtext.go @@ -1,12 +1,12 @@ package codebase import ( + "neo-code/internal/tools" "context" "encoding/json" "strings" "neo-code/internal/repository" - "neo-code/internal/tools" ) // SearchTextTool implements the codebase_search_text tool. @@ -53,10 +53,6 @@ func (t *SearchTextTool) Schema() map[string]any { } } -func (t *SearchTextTool) MicroCompactPolicy() tools.MicroCompactPolicy { - return tools.MicroCompactPolicyCompact -} - func (t *SearchTextTool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) { var in struct { Query string `json:"query"` diff --git a/internal/tools/codebase/searchtext_test.go b/internal/tools/codebase/searchtext_test.go index 06741a6b3..7cf87fe1a 100644 --- a/internal/tools/codebase/searchtext_test.go +++ b/internal/tools/codebase/searchtext_test.go @@ -33,9 +33,6 @@ func TestSearchTextToolMetadata(t *testing.T) { if _, hasQuery := props["query"]; !hasQuery { t.Fatalf("Schema should have query property") } - if tool.MicroCompactPolicy() != tools.MicroCompactPolicyCompact { - t.Fatalf("MicroCompactPolicy() = %v, want Compact", tool.MicroCompactPolicy()) - } } func TestSearchTextToolInvalidJSON(t *testing.T) { diff --git a/internal/tools/diagnose/tool.go b/internal/tools/diagnose/tool.go index 4566d177f..1bf9e6b19 100644 --- a/internal/tools/diagnose/tool.go +++ b/internal/tools/diagnose/tool.go @@ -1,6 +1,7 @@ package diagnose import ( + "neo-code/internal/tools" "context" "encoding/json" "errors" @@ -11,7 +12,6 @@ import ( "time" "neo-code/internal/subagent" - "neo-code/internal/tools" ) const ( @@ -83,11 +83,6 @@ func (t *Tool) Schema() map[string]any { } } -// MicroCompactPolicy 保留诊断结果,避免短期压缩时丢失排障上下文。 -func (t *Tool) MicroCompactPolicy() tools.MicroCompactPolicy { - return tools.MicroCompactPolicyPreserveHistory -} - // Execute 校验输入并通过 SpawnSubAgent 能力链路执行真实诊断,失败时静默降级。 func (t *Tool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) { if err := ctx.Err(); err != nil { diff --git a/internal/tools/diagnose/tool_test.go b/internal/tools/diagnose/tool_test.go index 4fa53ac47..8fb3e3986 100644 --- a/internal/tools/diagnose/tool_test.go +++ b/internal/tools/diagnose/tool_test.go @@ -20,9 +20,6 @@ func TestToolMetadata(t *testing.T) { if tool.Schema() == nil { t.Fatal("Schema() should not be nil") } - if tool.MicroCompactPolicy() != tools.MicroCompactPolicyPreserveHistory { - t.Fatalf("MicroCompactPolicy() = %q, want %q", tool.MicroCompactPolicy(), tools.MicroCompactPolicyPreserveHistory) - } } func TestToolExecuteFallbackWhenInvokerUnavailable(t *testing.T) { diff --git a/internal/tools/filesystem/delete_file.go b/internal/tools/filesystem/delete_file.go index ae49cd185..4ab3138ea 100644 --- a/internal/tools/filesystem/delete_file.go +++ b/internal/tools/filesystem/delete_file.go @@ -1,6 +1,7 @@ package filesystem import ( + "neo-code/internal/tools" "context" "encoding/json" "errors" @@ -8,7 +9,6 @@ import ( "strings" "neo-code/internal/security" - "neo-code/internal/tools" ) type DeleteFileTool struct { @@ -44,10 +44,6 @@ func (t *DeleteFileTool) Schema() map[string]any { } } -func (t *DeleteFileTool) MicroCompactPolicy() tools.MicroCompactPolicy { - return tools.MicroCompactPolicyCompact -} - func (t *DeleteFileTool) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { var args deleteFileInput if err := json.Unmarshal(input.Arguments, &args); err != nil { diff --git a/internal/tools/filesystem/edit.go b/internal/tools/filesystem/edit.go index 2a07b6db2..c68d69c44 100644 --- a/internal/tools/filesystem/edit.go +++ b/internal/tools/filesystem/edit.go @@ -1,6 +1,7 @@ package filesystem import ( + "neo-code/internal/tools" "context" "encoding/json" "errors" @@ -9,7 +10,6 @@ import ( "strings" "neo-code/internal/security" - "neo-code/internal/tools" ) type EditTool struct { @@ -55,11 +55,6 @@ 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 3579ff8d0..50dfb7690 100644 --- a/internal/tools/filesystem/glob.go +++ b/internal/tools/filesystem/glob.go @@ -1,6 +1,7 @@ package filesystem import ( + "neo-code/internal/tools" "context" "encoding/json" "errors" @@ -10,7 +11,6 @@ import ( "sort" "strings" - "neo-code/internal/tools" ) type GlobTool struct { @@ -61,11 +61,6 @@ 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 7bf1deca0..29426f645 100644 --- a/internal/tools/filesystem/grep.go +++ b/internal/tools/filesystem/grep.go @@ -1,6 +1,7 @@ package filesystem import ( + "neo-code/internal/tools" "context" "encoding/json" "errors" @@ -10,7 +11,6 @@ import ( "regexp" "strings" - "neo-code/internal/tools" ) const defaultGrepResultLimit = 200 @@ -60,11 +60,6 @@ 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 c789fdd2b..ed122de4f 100644 --- a/internal/tools/filesystem/read_file.go +++ b/internal/tools/filesystem/read_file.go @@ -1,6 +1,7 @@ package filesystem import ( + "neo-code/internal/tools" "context" "encoding/json" "errors" @@ -10,7 +11,6 @@ import ( "strings" "neo-code/internal/security" - "neo-code/internal/tools" ) const emitChunkSize = 4 * 1024 @@ -63,11 +63,6 @@ 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/tool_metadata_test.go b/internal/tools/filesystem/tool_metadata_test.go index 717d30b6d..58ffc8165 100644 --- a/internal/tools/filesystem/tool_metadata_test.go +++ b/internal/tools/filesystem/tool_metadata_test.go @@ -3,7 +3,6 @@ package filesystem import ( "testing" - "neo-code/internal/tools" ) func TestFilesystemToolMetadata(t *testing.T) { @@ -14,15 +13,13 @@ func TestFilesystemToolMetadata(t *testing.T) { toolName string description string schema map[string]any - policy tools.MicroCompactPolicy }{ { name: "delete file", toolName: NewDelete("/workspace").Name(), description: NewDelete("/workspace").Description(), schema: NewDelete("/workspace").Schema(), - policy: NewDelete("/workspace").MicroCompactPolicy(), - }, + }, } for _, tt := range tests { @@ -42,9 +39,6 @@ func TestFilesystemToolMetadata(t *testing.T) { if !ok || len(required) == 0 { t.Fatalf("required schema fields missing: %#v", tt.schema["required"]) } - if tt.policy != tools.MicroCompactPolicyCompact { - t.Fatalf("policy = %q, want compact", tt.policy) - } }) } } diff --git a/internal/tools/filesystem/write_file.go b/internal/tools/filesystem/write_file.go index 1185fb259..8115d7391 100644 --- a/internal/tools/filesystem/write_file.go +++ b/internal/tools/filesystem/write_file.go @@ -1,6 +1,7 @@ package filesystem import ( + "neo-code/internal/tools" "context" "encoding/json" "errors" @@ -10,7 +11,6 @@ import ( "unicode/utf8" "neo-code/internal/security" - "neo-code/internal/tools" ) type WriteFileTool struct { @@ -61,11 +61,6 @@ 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 40764d90e..ecde307d8 100644 --- a/internal/tools/manager.go +++ b/internal/tools/manager.go @@ -27,8 +27,6 @@ type SpecListInput struct { // Manager is the runtime-facing tool execution and schema exposure boundary. type Manager interface { ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]providertypes.ToolSpec, error) - MicroCompactPolicy(name string) MicroCompactPolicy - MicroCompactSummarizer(name string) ContentSummarizer // Execute 必须支持并发调用;runtime 可能在同一轮中并行调度多个工具调用。 Execute(ctx context.Context, input ToolCallInput) (ToolResult, error) RememberSessionDecision(sessionID string, action security.Action, scope SessionPermissionScope) error @@ -42,11 +40,9 @@ type Executor interface { } type microCompactPolicyExecutor interface { - MicroCompactPolicy(name string) MicroCompactPolicy } type microCompactSummarizerExecutor interface { - MicroCompactSummarizer(name string) ContentSummarizer } // factsEnrichingExecutor 包装底层执行器,在不信任外部 metadata 的前提下补齐受信结构化事实。 @@ -72,21 +68,7 @@ func (e *factsEnrichingExecutor) Supports(name string) bool { return e.inner.Supports(name) } -// MicroCompactPolicy 透传被包装执行器的压缩策略,确保 UI/Runtime 行为与原实现一致。 -func (e *factsEnrichingExecutor) MicroCompactPolicy(name string) MicroCompactPolicy { - if source, ok := e.inner.(microCompactPolicyExecutor); ok { - return source.MicroCompactPolicy(name) - } - return MicroCompactPolicyCompact -} -// MicroCompactSummarizer 透传被包装执行器的摘要器实现,避免包装层吞掉摘要能力。 -func (e *factsEnrichingExecutor) MicroCompactSummarizer(name string) ContentSummarizer { - if source, ok := e.inner.(microCompactSummarizerExecutor); ok { - return source.MicroCompactSummarizer(name) - } - return nil -} // Execute 在执行后按本地权限动作补齐可信 facts,避免运行时依赖远端 metadata。 func (e *factsEnrichingExecutor) Execute(ctx context.Context, input ToolCallInput) (ToolResult, error) { @@ -324,27 +306,7 @@ func (m *DefaultManager) ListAvailableSpecs(ctx context.Context, input SpecListI return readOnlyFiltered, nil } -// 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 -} -// MicroCompactSummarizer 返回工具的内容摘要器;未注册时返回 nil。 -func (m *DefaultManager) MicroCompactSummarizer(name string) ContentSummarizer { - if m == nil || m.executor == nil { - return nil - } - if source, ok := m.executor.(microCompactSummarizerExecutor); ok { - return source.MicroCompactSummarizer(name) - } - return nil -} // Execute runs the tool if the permission engine allows it and the sandbox // check passes. diff --git a/internal/tools/manager_test.go b/internal/tools/manager_test.go index cc83dc9bc..e17dd781a 100644 --- a/internal/tools/manager_test.go +++ b/internal/tools/manager_test.go @@ -11,7 +11,6 @@ import ( "testing" "time" - providertypes "neo-code/internal/provider/types" "neo-code/internal/security" "neo-code/internal/tools/mcp" ) @@ -20,7 +19,6 @@ type managerStubTool struct { name string content string err error - policy MicroCompactPolicy callCount int lastCall ToolCallInput } @@ -31,7 +29,6 @@ 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 t.policy } func (t *managerStubTool) Execute(ctx context.Context, call ToolCallInput) (ToolResult, error) { t.callCount++ @@ -49,20 +46,9 @@ type stubSandbox struct { lastAction security.Action } -type executorWithoutOptionalCompactFeatures struct{} -func (executorWithoutOptionalCompactFeatures) ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]providertypes.ToolSpec, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - return nil, nil -} -func (executorWithoutOptionalCompactFeatures) Execute(ctx context.Context, call ToolCallInput) (ToolResult, error) { - return ToolResult{}, ctx.Err() -} -func (executorWithoutOptionalCompactFeatures) Supports(name string) bool { return false } func (s *stubSandbox) Check(ctx context.Context, action security.Action) (*security.WorkspaceExecutionPlan, error) { s.callCount++ @@ -135,92 +121,7 @@ func TestDefaultManagerListAvailableSpecsReadOnlyFiltersWriteTools(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(executorWithoutOptionalCompactFeatures{}, mustAllowEngine(t), 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, mustAllowEngine(t), 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 TestDefaultManagerMicroCompactSummarizer(t *testing.T) { - t.Parallel() - - t.Run("nil manager returns nil", func(t *testing.T) { - t.Parallel() - - var manager *DefaultManager - if got := manager.MicroCompactSummarizer("custom_tool"); got != nil { - t.Fatalf("expected nil summarizer, got non-nil") - } - }) - t.Run("executor without summarizer support returns nil", func(t *testing.T) { - t.Parallel() - - manager, err := NewManager(executorWithoutOptionalCompactFeatures{}, mustAllowEngine(t), nil) - if err != nil { - t.Fatalf("new manager: %v", err) - } - if got := manager.MicroCompactSummarizer("custom_tool"); got != nil { - t.Fatalf("expected nil summarizer, got non-nil") - } - }) - - t.Run("executor summarizer is forwarded", func(t *testing.T) { - t.Parallel() - - registry := NewRegistry() - registry.RegisterSummarizer("custom_tool", func(content string, metadata map[string]string, isError bool) string { - return "summary:" + content - }) - - manager, err := NewManager(registry, mustAllowEngine(t), nil) - if err != nil { - t.Fatalf("new manager: %v", err) - } - - summarizer := manager.MicroCompactSummarizer("CUSTOM_TOOL") - if summarizer == nil { - t.Fatal("expected non-nil summarizer") - } - if got := summarizer("content", nil, false); got != "summary:content" { - t.Fatalf("unexpected summary output: %q", got) - } - }) -} func TestDefaultManagerListAvailableSpecsBoundaries(t *testing.T) { t.Parallel() diff --git a/internal/tools/memo/list.go b/internal/tools/memo/list.go index 72b2676eb..61a892b6d 100644 --- a/internal/tools/memo/list.go +++ b/internal/tools/memo/list.go @@ -1,13 +1,13 @@ package memo import ( + "neo-code/internal/tools" "context" "encoding/json" "fmt" "strings" "neo-code/internal/memo" - "neo-code/internal/tools" ) const listToolName = tools.ToolNameMemoList @@ -44,11 +44,6 @@ func (t *ListTool) Schema() map[string]any { } } -// MicroCompactPolicy 记忆目录结果应保留在上下文中,不参与 micro compact 清理。 -func (t *ListTool) MicroCompactPolicy() tools.MicroCompactPolicy { - return tools.MicroCompactPolicyPreserveHistory -} - // Execute 执行 memo_list 工具调用。 func (t *ListTool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) { if t.svc == nil { diff --git a/internal/tools/memo/list_test.go b/internal/tools/memo/list_test.go index 7062039fd..60ab39c1f 100644 --- a/internal/tools/memo/list_test.go +++ b/internal/tools/memo/list_test.go @@ -21,9 +21,6 @@ func TestListToolName(t *testing.T) { if tool.Schema() == nil { t.Fatal("Schema() should not be nil") } - if tool.MicroCompactPolicy() != tools.MicroCompactPolicyPreserveHistory { - t.Fatalf("MicroCompactPolicy() = %v, want PreserveHistory", tool.MicroCompactPolicy()) - } } func TestListToolExecuteEmpty(t *testing.T) { diff --git a/internal/tools/memo/recall.go b/internal/tools/memo/recall.go index dfcc2cdff..389f2da35 100644 --- a/internal/tools/memo/recall.go +++ b/internal/tools/memo/recall.go @@ -1,6 +1,7 @@ package memo import ( + "neo-code/internal/tools" "context" "encoding/json" "fmt" @@ -8,7 +9,6 @@ import ( "strings" "neo-code/internal/memo" - "neo-code/internal/tools" ) const ( @@ -56,11 +56,6 @@ func (t *RecallTool) Schema() map[string]any { } } -// MicroCompactPolicy 记忆读取结果应保留在上下文中,不参与 micro compact 清理。 -func (t *RecallTool) MicroCompactPolicy() tools.MicroCompactPolicy { - return tools.MicroCompactPolicyPreserveHistory -} - // Execute 执行 memo_recall 工具调用。调用前须确保 svc 已通过构造函数注入。 func (t *RecallTool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) { var args recallInput diff --git a/internal/tools/memo/recall_test.go b/internal/tools/memo/recall_test.go index 04cbf1c7e..93524e37c 100644 --- a/internal/tools/memo/recall_test.go +++ b/internal/tools/memo/recall_test.go @@ -87,9 +87,6 @@ func TestRecallToolDescriptionAndSchema(t *testing.T) { if schema == nil { t.Fatal("Schema() should not be nil") } - if tool.MicroCompactPolicy() != tools.MicroCompactPolicyPreserveHistory { - t.Fatalf("MicroCompactPolicy() = %v, want PreserveHistory", tool.MicroCompactPolicy()) - } } func TestRecallToolExecuteWithScopeFilter(t *testing.T) { diff --git a/internal/tools/memo/remember.go b/internal/tools/memo/remember.go index 5009ef012..8c28d4793 100644 --- a/internal/tools/memo/remember.go +++ b/internal/tools/memo/remember.go @@ -1,13 +1,13 @@ package memo import ( + "neo-code/internal/tools" "context" "encoding/json" "fmt" "strings" "neo-code/internal/memo" - "neo-code/internal/tools" ) const ( @@ -69,11 +69,6 @@ func (t *RememberTool) Schema() map[string]any { } } -// MicroCompactPolicy 记忆写入结果应保留在上下文中,不参与 micro compact 清理。 -func (t *RememberTool) MicroCompactPolicy() tools.MicroCompactPolicy { - return tools.MicroCompactPolicyPreserveHistory -} - // Execute 执行 memo_remember 工具调用。调用前须确保 svc 已通过构造函数注入。 func (t *RememberTool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) { var args rememberInput diff --git a/internal/tools/memo/remove.go b/internal/tools/memo/remove.go index 6f5e0c811..9bdb79897 100644 --- a/internal/tools/memo/remove.go +++ b/internal/tools/memo/remove.go @@ -1,13 +1,13 @@ package memo import ( + "neo-code/internal/tools" "context" "encoding/json" "fmt" "strings" "neo-code/internal/memo" - "neo-code/internal/tools" ) const removeToolName = tools.ToolNameMemoRemove @@ -50,11 +50,6 @@ func (t *RemoveTool) Schema() map[string]any { } } -// MicroCompactPolicy 删除结果应保留在上下文中,不参与 micro compact 清理。 -func (t *RemoveTool) MicroCompactPolicy() tools.MicroCompactPolicy { - return tools.MicroCompactPolicyPreserveHistory -} - // Execute 执行 memo_remove 工具调用。 func (t *RemoveTool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) { var args removeInput diff --git a/internal/tools/memo/remove_test.go b/internal/tools/memo/remove_test.go index bcf5d9a8a..4b96f3a39 100644 --- a/internal/tools/memo/remove_test.go +++ b/internal/tools/memo/remove_test.go @@ -21,9 +21,6 @@ func TestRemoveToolName(t *testing.T) { if tool.Schema() == nil { t.Fatal("Schema() should not be nil") } - if tool.MicroCompactPolicy() != tools.MicroCompactPolicyPreserveHistory { - t.Fatalf("MicroCompactPolicy() = %v, want PreserveHistory", tool.MicroCompactPolicy()) - } } func TestRemoveToolExecuteSuccess(t *testing.T) { diff --git a/internal/tools/micro_compact_policy.go b/internal/tools/micro_compact_policy.go deleted file mode 100644 index 9225e9bcc..000000000 --- a/internal/tools/micro_compact_policy.go +++ /dev/null @@ -1,11 +0,0 @@ -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/micro_compact_summarizer.go b/internal/tools/micro_compact_summarizer.go deleted file mode 100644 index ad5a8274e..000000000 --- a/internal/tools/micro_compact_summarizer.go +++ /dev/null @@ -1,6 +0,0 @@ -package tools - -// ContentSummarizer 将工具结果内容压缩为短摘要,用于 micro-compact 替换旧工具输出。 -// content 和 metadata 来自持久化后的 Message 字段,isError 标识原始工具是否报错。 -// 返回空字符串表示"无摘要,回退到默认清除行为"。 -type ContentSummarizer func(content string, metadata map[string]string, isError bool) string diff --git a/internal/tools/micro_compact_summarizer_test.go b/internal/tools/micro_compact_summarizer_test.go deleted file mode 100644 index bf3cee474..000000000 --- a/internal/tools/micro_compact_summarizer_test.go +++ /dev/null @@ -1,460 +0,0 @@ -package tools - -import ( - "strings" - "sync" - "testing" - "unicode/utf8" -) - -// stubMetadata 快速构建测试用 metadata map。 -func stubMetadata(keyValue ...string) map[string]string { - m := make(map[string]string, len(keyValue)/2) - for i := 0; i+1 < len(keyValue); i += 2 { - m[keyValue[i]] = keyValue[i+1] - } - return m -} - -func assertContains(t *testing.T, got, expected string) { - t.Helper() - if !strings.Contains(got, expected) { - t.Fatalf("expected %q in summary, got %q", expected, got) - } -} - -func assertMaxRuneCount(t *testing.T, got string, max int) { - t.Helper() - if utf8.RuneCountInString(got) > max { - t.Fatalf("summary exceeds %d runes: %d", max, utf8.RuneCountInString(got)) - } -} - -func assertEmptySummary(t *testing.T, got string) { - t.Helper() - if got != "" { - t.Fatalf("expected empty string, got %q", got) - } -} - -func TestBashSummarizer(t *testing.T) { - t.Parallel() - - t.Run("normal_output", func(t *testing.T) { - content := "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8" - meta := stubMetadata("workdir", "/home/user/project") - got := bashSummarizer(content, meta, false) - assertContains(t, got, "[exit=0]") - assertContains(t, got, "workdir=/home/user/project") - assertContains(t, got, "lines=8") - assertContains(t, got, "chars=") - assertMaxRuneCount(t, got, summaryMaxRunes) - }) - - t.Run("error_output", func(t *testing.T) { - content := "error: command not found" - meta := stubMetadata("workdir", "/tmp") - got := bashSummarizer(content, meta, true) - assertContains(t, got, "[exit=non-zero]") - }) - - t.Run("short_output", func(t *testing.T) { - content := "ok" - got := bashSummarizer(content, nil, false) - assertContains(t, got, "lines=1") - }) - - t.Run("empty_content", func(t *testing.T) { - got := bashSummarizer("", nil, false) - assertContains(t, got, "[exit=0]") - }) - - t.Run("sanitizes_workdir_metadata", func(t *testing.T) { - meta := stubMetadata("workdir", " \n\t/tmp/proj\x07 ") - got := bashSummarizer("ok", meta, false) - if strings.ContainsAny(got, "\n\t\a") { - t.Fatalf("expected sanitized workdir without control characters, got %q", got) - } - assertContains(t, got, "workdir=/tmp/proj") - }) -} - -func TestReadFileSummarizer(t *testing.T) { - t.Parallel() - - t.Run("normal_file", func(t *testing.T) { - content := "package main\n\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n" - meta := stubMetadata("path", "/home/user/main.go") - got := readFileSummarizer(content, meta, false) - assertContains(t, got, "/home/user/main.go") - assertContains(t, got, "lines=5") - assertContains(t, got, "chars=") - assertMaxRuneCount(t, got, summaryMaxRunes) - }) - - t.Run("trailing_newline_not_counted_as_extra_line", func(t *testing.T) { - content := "a\nb\n" - meta := stubMetadata("path", "/tmp/a.txt") - got := readFileSummarizer(content, meta, false) - assertContains(t, got, "lines=2") - }) - - t.Run("empty_lines_are_counted", func(t *testing.T) { - content := "\n\n" - meta := stubMetadata("path", "/tmp/empty.txt") - got := readFileSummarizer(content, meta, false) - assertContains(t, got, "lines=2") - }) - - t.Run("missing_path", func(t *testing.T) { - got := readFileSummarizer("content", nil, false) - assertEmptySummary(t, got) - }) - - t.Run("sanitizes_path_metadata", func(t *testing.T) { - content := "line1\nline2" - meta := stubMetadata("path", " \n\t/tmp/a.go\x07 ") - got := readFileSummarizer(content, meta, false) - if strings.ContainsAny(got, "\n\t\a") { - t.Fatalf("expected sanitized path without control characters, got %q", got) - } - assertContains(t, got, "/tmp/a.go") - }) -} - -func TestWriteFileSummarizer(t *testing.T) { - t.Parallel() - - t.Run("normal", func(t *testing.T) { - meta := stubMetadata("path", "/home/user/test.go", "bytes", "1024") - got := writeFileSummarizer("", meta, false) - assertContains(t, got, "/home/user/test.go") - assertContains(t, got, "1024 bytes") - assertMaxRuneCount(t, got, summaryMaxRunes) - }) - - t.Run("missing_path", func(t *testing.T) { - got := writeFileSummarizer("", stubMetadata("bytes", "100"), false) - assertEmptySummary(t, got) - }) - - t.Run("sanitizes_path_metadata", func(t *testing.T) { - meta := stubMetadata("path", " \n\t/tmp/out.go\x07 ", "bytes", "4") - got := writeFileSummarizer("", meta, false) - if strings.ContainsAny(got, "\n\t\a") { - t.Fatalf("expected sanitized path without control characters, got %q", got) - } - assertContains(t, got, "/tmp/out.go") - }) -} - -func TestEditSummarizer(t *testing.T) { - t.Parallel() - - t.Run("with_relative_path", func(t *testing.T) { - meta := stubMetadata("relative_path", "src/main.go", "path", "/abs/src/main.go", "search_length", "50", "replacement_length", "60") - got := editSummarizer("", meta, false) - assertContains(t, got, "src/main.go") - assertContains(t, got, "search=50") - assertMaxRuneCount(t, got, summaryMaxRunes) - }) - - t.Run("fallback_to_abs_path", func(t *testing.T) { - meta := stubMetadata("path", "/abs/src/main.go", "search_length", "10", "replacement_length", "20") - got := editSummarizer("", meta, false) - assertContains(t, got, "/abs/src/main.go") - }) - - t.Run("missing_path", func(t *testing.T) { - got := editSummarizer("", stubMetadata("search_length", "10"), false) - assertEmptySummary(t, got) - }) - - t.Run("sanitizes_path_metadata", func(t *testing.T) { - meta := stubMetadata("relative_path", " \n\tsrc/main.go\x07 ", "search_length", "10", "replacement_length", "12") - got := editSummarizer("", meta, false) - if strings.ContainsAny(got, "\n\t\a") { - t.Fatalf("expected sanitized path without control characters, got %q", got) - } - assertContains(t, got, "src/main.go") - }) - - t.Run("long_path_is_truncated", func(t *testing.T) { - longPath := strings.Repeat("abcdef/", 80) + "main.go" - meta := stubMetadata("path", longPath, "search_length", "10", "replacement_length", "20") - got := editSummarizer("", meta, false) - assertMaxRuneCount(t, got, summaryMaxRunes+3) - }) -} - -func TestGrepSummarizer(t *testing.T) { - t.Parallel() - - t.Run("with_matches", func(t *testing.T) { - content := "src/a.go:10:match1\nsrc/b.go:20:match2\nsrc/c.go:30:match3\nsrc/d.go:40:match4" - meta := stubMetadata("root", "/home/user", "matched_files", "4", "matched_lines", "4") - got := grepSummarizer(content, meta, false) - assertContains(t, got, "root=/home/user") - assertContains(t, got, "files=4") - assertMaxRuneCount(t, got, summaryMaxRunes) - }) - - t.Run("empty_content", func(t *testing.T) { - meta := stubMetadata("root", "/home", "matched_files", "0", "matched_lines", "0") - got := grepSummarizer("", meta, false) - assertContains(t, got, "files=0") - }) - - t.Run("sanitizes_root_metadata", func(t *testing.T) { - content := "a.go:1:x" - meta := stubMetadata("root", " \n\t/tmp/root\x07 ", "matched_files", "1", "matched_lines", "1") - got := grepSummarizer(content, meta, false) - if strings.ContainsAny(got, "\n\t\a") { - t.Fatalf("expected sanitized root without control characters, got %q", got) - } - assertContains(t, got, "root=/tmp/root") - }) - - t.Run("sanitizes_injected_filename", func(t *testing.T) { - content := "src/a.go\nignore:1:x\nsafe.go:2:y" - meta := stubMetadata("matched_files", "2", "matched_lines", "2") - got := grepSummarizer(content, meta, false) - if strings.Contains(got, "\n") || strings.Contains(got, "\t") { - t.Fatalf("expected sanitized summary without control characters, got %q", got) - } - assertContains(t, got, "matches=ignore, safe.go") - }) -} - -func TestGlobSummarizer(t *testing.T) { - t.Parallel() - - t.Run("with_files", func(t *testing.T) { - content := "src/a.go\nsrc/b.go\nsrc/c.go\nsrc/d.go" - meta := stubMetadata("count", "4") - got := globSummarizer(content, meta, false) - assertContains(t, got, "4 files") - assertMaxRuneCount(t, got, summaryMaxRunes) - }) - - t.Run("no_matches", func(t *testing.T) { - meta := stubMetadata("count", "0") - got := globSummarizer("", meta, false) - assertContains(t, got, "0 files") - }) - - t.Run("skips_blank_and_control_lines", func(t *testing.T) { - content := "\n\t\nsrc/a.go\nsrc/b.go\n" - meta := stubMetadata("count", "2") - got := globSummarizer(content, meta, false) - assertContains(t, got, "src/a.go, src/b.go") - if strings.Contains(got, "\n") || strings.Contains(got, "\t") { - t.Fatalf("expected sanitized preview, got %q", got) - } - }) -} - -func TestWebfetchSummarizer(t *testing.T) { - t.Parallel() - - t.Run("with_truncated_flag", func(t *testing.T) { - meta := stubMetadata("truncated", "true") - got := webfetchSummarizer("", meta, false) - assertContains(t, got, "truncated=true") - }) - - t.Run("minimal", func(t *testing.T) { - got := webfetchSummarizer("", nil, false) - assertContains(t, got, "[summary] webfetch") - }) -} - -func TestRegisterBuiltinSummarizers(t *testing.T) { - t.Parallel() - - registry := NewRegistry() - RegisterBuiltinSummarizers(registry) - - toolNames := []string{ - ToolNameBash, ToolNameFilesystemReadFile, ToolNameCodebaseRead, ToolNameFilesystemWriteFile, - ToolNameFilesystemEdit, ToolNameFilesystemGrep, ToolNameFilesystemGlob, - ToolNameWebFetch, - } - for _, name := range toolNames { - if registry.MicroCompactSummarizer(name) == nil { - t.Errorf("expected summarizer for %q to be registered", name) - } - } - - // 不在注册列表中的工具应返回 nil - if registry.MicroCompactSummarizer("unknown_tool") != nil { - t.Fatal("expected nil for unknown tool") - } -} - -func TestRegisterBuiltinSummarizersNilRegistry(t *testing.T) { - t.Parallel() - RegisterBuiltinSummarizers(nil) -} - -func TestRegisterSummarizer(t *testing.T) { - t.Parallel() - - registry := NewRegistry() - - // 注册 - called := false - registry.RegisterSummarizer("test_tool", func(content string, metadata map[string]string, isError bool) string { - called = true - return "summary" - }) - - s := registry.MicroCompactSummarizer("test_tool") - if s == nil { - t.Fatal("expected summarizer to be registered") - } - result := s("content", nil, false) - if !called { - t.Fatal("expected summarizer to be called") - } - if result != "summary" { - t.Fatalf("expected 'summary', got %q", result) - } - - // 移除 - registry.RegisterSummarizer("test_tool", nil) - if registry.MicroCompactSummarizer("test_tool") != nil { - t.Fatal("expected nil after removal") - } -} - -func TestRegisterSummarizerNormalizesName(t *testing.T) { - t.Parallel() - - registry := NewRegistry() - registry.RegisterSummarizer(" Mixed_Tool ", func(content string, metadata map[string]string, isError bool) string { - return "ok" - }) - - if registry.MicroCompactSummarizer("mixed_tool") == nil { - t.Fatal("expected normalized summarizer lookup") - } - if registry.MicroCompactSummarizer(" MIXED_TOOL ") == nil { - t.Fatal("expected case-insensitive summarizer lookup") - } -} - -func TestRegisterSummarizerNilRegistry(t *testing.T) { - t.Parallel() - - var nilRegistry *Registry - nilRegistry.RegisterSummarizer("tool", func(content string, metadata map[string]string, isError bool) string { - return "ok" - }) - if nilRegistry.MicroCompactSummarizer("tool") != nil { - t.Fatal("expected nil summarizer on nil registry") - } -} - -func TestRegisterSummarizerConcurrentAccess(t *testing.T) { - t.Parallel() - - registry := NewRegistry() - var wg sync.WaitGroup - - for i := 0; i < 8; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for j := 0; j < 200; j++ { - if j%3 == 0 { - registry.RegisterSummarizer("concurrent_tool", nil) - continue - } - registry.RegisterSummarizer("concurrent_tool", func(content string, metadata map[string]string, isError bool) string { - return "worker" - }) - s := registry.MicroCompactSummarizer("concurrent_tool") - if s != nil { - _ = s("content", nil, false) - } - } - }() - } - - wg.Wait() -} - -func TestTruncateRunes(t *testing.T) { - t.Parallel() - - t.Run("short", func(t *testing.T) { - got := truncateRunes("hello", 10) - if got != "hello" { - t.Fatalf("expected unchanged, got %q", got) - } - }) - - t.Run("exact", func(t *testing.T) { - got := truncateRunes("hello", 5) - if got != "hello" { - t.Fatalf("expected unchanged, got %q", got) - } - }) - - t.Run("truncated", func(t *testing.T) { - got := truncateRunes("hello world", 5) - if got != "hello..." { - t.Fatalf("expected 'hello...', got %q", got) - } - }) - - t.Run("chinese", func(t *testing.T) { - got := truncateRunes("你好世界测试", 3) - if got != "你好世..." { - t.Fatalf("expected '你好世...', got %q", got) - } - }) - - t.Run("zero_limit_keeps_original", func(t *testing.T) { - got := truncateRunes("hello", 0) - if got != "hello" { - t.Fatalf("expected unchanged with zero limit, got %q", got) - } - }) - - t.Run("empty_text", func(t *testing.T) { - got := truncateRunes("", 10) - if got != "" { - t.Fatalf("expected empty string, got %q", got) - } - }) -} - -func TestStableLineCount(t *testing.T) { - t.Parallel() - - t.Run("empty", func(t *testing.T) { - if got := stableLineCount(""); got != 0 { - t.Fatalf("expected 0, got %d", got) - } - }) - - t.Run("non_empty", func(t *testing.T) { - if got := stableLineCount("a\nb"); got != 2 { - t.Fatalf("expected 2, got %d", got) - } - }) - - t.Run("trailing_newline", func(t *testing.T) { - if got := stableLineCount("a\nb\n"); got != 2 { - t.Fatalf("expected 2, got %d", got) - } - }) - - t.Run("only_empty_lines", func(t *testing.T) { - if got := stableLineCount("\n\n"); got != 2 { - t.Fatalf("expected 2, got %d", got) - } - }) -} diff --git a/internal/tools/micro_compact_summarizers_builtin.go b/internal/tools/micro_compact_summarizers_builtin.go deleted file mode 100644 index de8c4886f..000000000 --- a/internal/tools/micro_compact_summarizers_builtin.go +++ /dev/null @@ -1,282 +0,0 @@ -package tools - -import ( - "strconv" - "strings" - "unicode/utf8" -) - -type builtinSummarizerRegistration struct { - toolName string - summarizer ContentSummarizer -} - -var builtinSummarizers = []builtinSummarizerRegistration{ - {toolName: ToolNameBash, summarizer: bashSummarizer}, - {toolName: ToolNameFilesystemReadFile, summarizer: readFileSummarizer}, - {toolName: ToolNameCodebaseRead, summarizer: readFileSummarizer}, - {toolName: ToolNameFilesystemWriteFile, summarizer: writeFileSummarizer}, - {toolName: ToolNameFilesystemEdit, summarizer: editSummarizer}, - {toolName: ToolNameFilesystemGrep, summarizer: grepSummarizer}, - {toolName: ToolNameFilesystemGlob, summarizer: globSummarizer}, - {toolName: ToolNameWebFetch, summarizer: webfetchSummarizer}, -} - -// RegisterBuiltinSummarizers 将所有内置工具的内容摘要器注册到 Registry。 -// 建议在启动装配阶段调用;可重复调用并覆盖同名摘要器。 -func RegisterBuiltinSummarizers(registry *Registry) { - if registry == nil { - return - } - for _, item := range builtinSummarizers { - registry.RegisterSummarizer(item.toolName, item.summarizer) - } -} - -const summaryMaxRunes = 200 -const metadataTokenMaxRunes = 120 - -// bashSummarizer 仅保留结构化执行元信息,避免把原始输出内容重新注入上下文。 -func bashSummarizer(content string, metadata map[string]string, isError bool) string { - var parts []string - - if isError { - parts = append(parts, "[exit=non-zero]") - } else { - parts = append(parts, "[exit=0]") - } - - if workdir := metadataToken(metadata["workdir"]); workdir != "" { - parts = append(parts, "workdir="+workdir) - } - - trimmed := strings.TrimSpace(content) - if trimmed != "" { - parts = appendTextStats(parts, trimmed) - } - - return truncateRunes(strings.Join(parts, " "), summaryMaxRunes) -} - -// readFileSummarizer 仅保留稳定元信息,避免在摘要中再次暴露文件正文。 -func readFileSummarizer(content string, metadata map[string]string, isError bool) string { - path := metadataToken(metadata["path"]) - if path == "" { - return "" - } - - lineCount := stableLineCount(content) - - var parts []string - parts = append(parts, "[summary]", path, "lines="+strconv.Itoa(lineCount)) - if content != "" { - parts = append(parts, "chars="+strconv.Itoa(utf8.RuneCountInString(content))) - } - - return truncateRunes(strings.Join(parts, " "), summaryMaxRunes) -} - -// writeFileSummarizer 保留文件路径与写入字节数。 -func writeFileSummarizer(content string, metadata map[string]string, isError bool) string { - path := metadataToken(metadata["path"]) - if path == "" { - return "" - } - bytes := metadata["bytes"] - return truncateRunes("[summary] wrote "+path+" ("+bytes+" bytes)", summaryMaxRunes) -} - -// editSummarizer 保留编辑路径与替换范围。 -func editSummarizer(content string, metadata map[string]string, isError bool) string { - path := metadataToken(metadata["relative_path"]) - if path == "" { - path = metadataToken(metadata["path"]) - } - if path == "" { - return "" - } - searchLen := metadata["search_length"] - replaceLen := metadata["replacement_length"] - return truncateRunes( - "[summary] edited "+path+" (search="+searchLen+" chars, replace="+replaceLen+" chars)", - summaryMaxRunes, - ) -} - -// grepSummarizer 保留搜索根目录、匹配计数与前若干文件名。 -func grepSummarizer(content string, metadata map[string]string, isError bool) string { - var parts []string - parts = append(parts, "[summary] grep") - - if root := metadataToken(metadata["root"]); root != "" { - parts = append(parts, "root="+root) - } - - if matchedFiles := metadata["matched_files"]; matchedFiles != "" { - parts = append(parts, "files="+matchedFiles) - } - if matchedLines := metadata["matched_lines"]; matchedLines != "" { - parts = append(parts, "lines="+matchedLines) - } - - // 从 content 中提取前几个不重复文件名,避免对整段输出做全量切分。 - fileNames := extractUniqueMatchFiles(content, 3) - if len(fileNames) > 0 { - parts = append(parts, "matches="+strings.Join(fileNames, ", ")) - } - - return truncateRunes(strings.Join(parts, " "), summaryMaxRunes) -} - -// globSummarizer 保留匹配计数与前若干文件名。 -func globSummarizer(content string, metadata map[string]string, isError bool) string { - count := metadata["count"] - if count == "" { - count = "?" - } - - preview := collectPreviewLines(content, 3) - - var parts []string - parts = append(parts, "[summary] glob", count+" files") - if len(preview) > 0 { - parts = append(parts, strings.Join(preview, ", ")) - } - - return truncateRunes(strings.Join(parts, " "), summaryMaxRunes) -} - -// webfetchSummarizer 保留可稳定持久化的 webfetch 结果标记。 -func webfetchSummarizer(content string, metadata map[string]string, isError bool) string { - var parts []string - parts = append(parts, "[summary] webfetch") - - if truncated := metadata["truncated"]; truncated == "true" { - parts = append(parts, "truncated=true") - } - - return truncateRunes(strings.Join(parts, " "), summaryMaxRunes) -} - -// truncateRunes 按 rune 数量截断字符串,超出时追加 "..."。 -func truncateRunes(text string, maxRunes int) string { - if maxRunes <= 0 || text == "" { - return text - } - if utf8.RuneCountInString(text) <= maxRunes { - return text - } - runes := []rune(text) - return string(runes[:maxRunes]) + "..." -} - -// stableLineCount 统计文本行数;空文本返回 0,末尾换行不会产生额外空行计数。 -func stableLineCount(text string) int { - if text == "" { - return 0 - } - count := strings.Count(text, "\n") + 1 - if strings.HasSuffix(text, "\n") { - count-- - } - if count < 0 { - return 0 - } - return count -} - -// appendTextStats 为摘要补充文本统计字段,保持统一的结构化输出格式。 -func appendTextStats(parts []string, text string) []string { - return append(parts, - "lines="+strconv.Itoa(stableLineCount(text)), - "chars="+strconv.Itoa(utf8.RuneCountInString(text)), - ) -} - -// extractUniqueMatchFiles 按行扫描 grep 输出,提取前若干个去重后的文件名摘要。 -func extractUniqueMatchFiles(content string, limit int) []string { - if limit <= 0 { - return nil - } - - seen := make(map[string]struct{}, limit) - result := make([]string, 0, limit) - remaining := content - for len(remaining) > 0 && len(result) < limit { - line, rest := nextLine(remaining) - remaining = rest - - colon := strings.Index(line, ":") - if colon <= 0 { - continue - } - - file := sanitizeSummaryToken(line[:colon], 80) - if file == "" { - continue - } - if _, ok := seen[file]; ok { - continue - } - seen[file] = struct{}{} - result = append(result, file) - } - return result -} - -// collectPreviewLines 按行扫描输出并提取前若干个非空预览,避免全量 Split 带来的额外分配。 -func collectPreviewLines(content string, limit int) []string { - if limit <= 0 { - return nil - } - - result := make([]string, 0, limit) - remaining := content - for len(remaining) > 0 && len(result) < limit { - line, rest := nextLine(remaining) - remaining = rest - - clean := sanitizeSummaryToken(line, 100) - if clean == "" { - continue - } - result = append(result, clean) - } - return result -} - -// nextLine 返回 text 的首行及余下文本,兼容存在或不存在换行符的输入。 -func nextLine(text string) (line string, rest string) { - idx := strings.IndexByte(text, '\n') - if idx < 0 { - return text, "" - } - return text[:idx], text[idx+1:] -} - -// sanitizeSummaryToken 清理不可见控制字符并裁剪长度,降低摘要注入风险。 -func sanitizeSummaryToken(text string, maxRunes int) string { - trimmed := strings.TrimSpace(text) - if trimmed == "" { - return "" - } - - var b strings.Builder - b.Grow(len(trimmed)) - for _, r := range trimmed { - if r < 32 || r == 127 { - continue - } - b.WriteRune(r) - } - clean := strings.TrimSpace(b.String()) - if clean == "" { - return "" - } - return truncateRunes(clean, maxRunes) -} - -// metadataToken 统一清理 metadata 中可回灌到摘要的文本字段。 -func metadataToken(text string) string { - return sanitizeSummaryToken(text, metadataTokenMaxRunes) -} diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 16aee9168..4a7aa8e69 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -14,9 +14,6 @@ import ( type Registry struct { tools map[string]Tool - microCompactPolicies map[string]MicroCompactPolicy - microCompactSummarizers map[string]ContentSummarizer - microCompactSummaryMu sync.RWMutex mcpMu sync.RWMutex mcpRegistry *mcp.Registry mcpFactory *mcp.AdapterFactory @@ -26,9 +23,7 @@ type Registry struct { func NewRegistry() *Registry { return &Registry{ - tools: map[string]Tool{}, - microCompactPolicies: map[string]MicroCompactPolicy{}, - microCompactSummarizers: map[string]ContentSummarizer{}, + tools: map[string]Tool{}, } } @@ -95,12 +90,6 @@ func (r *Registry) Register(tool 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) { @@ -119,48 +108,8 @@ func (r *Registry) Supports(name string) bool { return r.supportsMCPTool(name) } -// 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 -} -// RegisterSummarizer 为指定工具注册内容摘要器;传入 nil 移除已有条目。 -func (r *Registry) RegisterSummarizer(toolName string, summarizer ContentSummarizer) { - if r == nil { - return - } - name := strings.ToLower(strings.TrimSpace(toolName)) - r.microCompactSummaryMu.Lock() - defer r.microCompactSummaryMu.Unlock() - if summarizer == nil { - delete(r.microCompactSummarizers, name) - return - } - r.microCompactSummarizers[name] = summarizer -} -// MicroCompactSummarizer 返回指定工具的内容摘要器;无注册时返回 nil。 -func (r *Registry) MicroCompactSummarizer(name string) ContentSummarizer { - if r == nil { - return nil - } - r.microCompactSummaryMu.RLock() - defer r.microCompactSummaryMu.RUnlock() - if r.microCompactSummarizers == nil { - return nil - } - return r.microCompactSummarizers[strings.ToLower(strings.TrimSpace(name))] -} func (r *Registry) GetSpecs() []providertypes.ToolSpec { names := make([]string, 0, len(r.tools)) diff --git a/internal/tools/registry_test.go b/internal/tools/registry_test.go index 1191317cc..1824fdd47 100644 --- a/internal/tools/registry_test.go +++ b/internal/tools/registry_test.go @@ -14,7 +14,6 @@ type stubTool struct { name string description string schema map[string]any - policy MicroCompactPolicy result ToolResult err error } @@ -24,9 +23,6 @@ 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 } @@ -166,12 +162,6 @@ 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" { @@ -194,42 +184,7 @@ func TestRegistryHelpers(t *testing.T) { } } -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) - } -} -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) - } -} func TestRegistryRememberSessionDecisionUnsupported(t *testing.T) { t.Parallel() diff --git a/internal/tools/spawnsubagent/tool.go b/internal/tools/spawnsubagent/tool.go index 95c07335c..325fc4a4d 100644 --- a/internal/tools/spawnsubagent/tool.go +++ b/internal/tools/spawnsubagent/tool.go @@ -1,6 +1,7 @@ package spawnsubagent import ( + "neo-code/internal/tools" "context" "crypto/sha1" "encoding/hex" @@ -12,7 +13,6 @@ import ( "time" "neo-code/internal/subagent" - "neo-code/internal/tools" ) const ( @@ -113,11 +113,6 @@ func (t *Tool) Schema() map[string]any { } } -// MicroCompactPolicy 保留子代理结果,避免短期压缩时丢失分析链路与结论。 -func (t *Tool) MicroCompactPolicy() tools.MicroCompactPolicy { - return tools.MicroCompactPolicyPreserveHistory -} - // Execute 解析入参后执行 inline 模式。 func (t *Tool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) { if err := ctx.Err(); err != nil { diff --git a/internal/tools/spawnsubagent/tool_test.go b/internal/tools/spawnsubagent/tool_test.go index c06c1b1bd..a551c5157 100644 --- a/internal/tools/spawnsubagent/tool_test.go +++ b/internal/tools/spawnsubagent/tool_test.go @@ -38,9 +38,6 @@ func TestToolMetadata(t *testing.T) { if strings.TrimSpace(tool.Description()) == "" { t.Fatalf("Description() should not be empty") } - if tool.MicroCompactPolicy() != tools.MicroCompactPolicyPreserveHistory { - t.Fatalf("MicroCompactPolicy() = %q, want %q", tool.MicroCompactPolicy(), tools.MicroCompactPolicyPreserveHistory) - } schema := tool.Schema() properties, ok := schema["properties"].(map[string]any) if !ok { diff --git a/internal/tools/todo/write.go b/internal/tools/todo/write.go index 716d64788..de82461ef 100644 --- a/internal/tools/todo/write.go +++ b/internal/tools/todo/write.go @@ -1,13 +1,13 @@ package todo import ( + "neo-code/internal/tools" "context" "errors" "fmt" "strings" agentsession "neo-code/internal/session" - "neo-code/internal/tools" ) // Tool 是会话级 Todo 读写工具实现。 @@ -263,11 +263,6 @@ func (t *Tool) Schema() map[string]any { } } -// MicroCompactPolicy 返回工具微压缩策略。 -func (t *Tool) MicroCompactPolicy() tools.MicroCompactPolicy { - return tools.MicroCompactPolicyCompact -} - // Execute 执行 todo_write 的 action 分发,并把变更写回会话。 func (t *Tool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) { if err := ctx.Err(); err != nil { diff --git a/internal/tools/todo/write_test.go b/internal/tools/todo/write_test.go index 0c11e6dfb..a944dae89 100644 --- a/internal/tools/todo/write_test.go +++ b/internal/tools/todo/write_test.go @@ -228,9 +228,6 @@ func TestToolMetadataMethods(t *testing.T) { if strings.TrimSpace(tool.Description()) == "" { t.Fatalf("Description() should not be empty") } - if tool.MicroCompactPolicy() != tools.MicroCompactPolicyCompact { - t.Fatalf("MicroCompactPolicy() should be compact") - } schema := tool.Schema() if schema["type"] != "object" { t.Fatalf("Schema() type = %+v", schema["type"]) diff --git a/internal/tools/types.go b/internal/tools/types.go index 554308c4f..f3589f848 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -15,7 +15,6 @@ 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 3b353902a..e34848d81 100644 --- a/internal/tools/webfetch/tool.go +++ b/internal/tools/webfetch/tool.go @@ -1,6 +1,7 @@ package webfetch import ( + "neo-code/internal/tools" "context" "encoding/json" "fmt" @@ -13,7 +14,6 @@ import ( "time" "neo-code/internal/config" - "neo-code/internal/tools" ) const ( @@ -116,11 +116,6 @@ 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 { diff --git a/www/reference/index.md b/www/reference/index.md index 702f1eb7c..666f1bc08 100644 --- a/www/reference/index.md +++ b/www/reference/index.md @@ -14,7 +14,7 @@ description: 汇总 NeoCode 仓库中已有设计文档,并说明它们分别 - [Runtime / Provider 事件流](https://github.com/1024XEngineer/neo-code/blob/main/docs/runtime-provider-event-flow.md) - 适合在你想理解 Provider 流式响应怎样进入 Runtime、事件怎样发给 UI 时阅读。 - [Context Compact 说明](https://github.com/1024XEngineer/neo-code/blob/main/docs/context-compact.md) - - 适合在你要调整压缩策略、自动压缩阈值或理解 micro compact 时阅读。 + - 适合在你要调整压缩策略或自动压缩阈值时阅读。 - [Session 持久化设计](https://github.com/1024XEngineer/neo-code/blob/main/docs/session-persistence-design.md) - 适合在你要理解会话恢复、状态落盘和会话模型边界时阅读。