diff --git a/README.md b/README.md
index 3ac91be..70ac2db 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,7 @@
| 日期 | 内容 |
|------|------|
+| 05-06 | **v1.3.0** EventBus事件总线 + ToolGateway权限隔离 + 错误策略路由(按error_type切换重试序列) + 认知门控(patch行数递减检测) + 3层渐进压缩 + Plan自动触发 + Worktree并行隔离 + Speculative Prefetch + SearchRouter意图感知搜索(arxiv/S2/GitHub/PyPI/Open-Meteo零key) + Wink自修复监控 |
| 05-06 | **v1.1.0** 熔断器+智能重试(syntax/import快速熔断+scope缩小) + Gate置信度 + Verifier结构化错误 + Experience Replay(BM25历史轨迹) + Session多轮连贯 + Locator精准裁剪 |
| 04-30 | 三层上下文架构 + SSH持久会话 + Gate/路由优化 + PCED-Lite多源聚合 + 搜索site:自动限定 + qwen3:8b 20题真实验证 + 13项bug修复 |
| 04-29 | 5元专家体系定稿 + 15个SKILL.md渐进加载 + DAG多任务编排 + Debug Subagent + Token预算/Guardrails/可观测性 |
@@ -50,13 +51,13 @@ KWCode 的思路不同:**LLM 只做分类和生成,确定性流水线做决
小模型窗口只有 8K-32K。对话几轮后 context 塞满,模型开始胡说。
-> KWCode 解法:**纯算法上下文压缩**(头尾保留 + 中间关键词提取,<10ms),自动在 context 快满时压缩历史对话。
+> KWCode 解法:**纯算法上下文压缩**(3层渐进压缩:70%裁剪tool冗余→85%压缩中间轮次→95%摘要化早期对话,<10ms),自动在 context 快满时分级压缩历史对话。
**痛点二:错误重复**
小模型修 bug 失败后,用同样的方式再试一遍,三次机会全浪费在同一个错误上。
-> KWCode 解法:**三阶段重试 + Reflection + Debug Subagent + 智能熔断**——第一次正常描述,第二次从错误信息出发(注入运行时调试数据),第三次最小化修改。语法错误/缺依赖自动熔断不浪费重试;同类错误3次自动停止;第2次失败自动缩小修改范围。
+> KWCode 解法:**错误策略路由 + 认知门控 + Wink自修复**——按error_type切换重试序列(syntax→熔断/import→确定性修复/runtime→先debug/patch_apply→重新定位);patch行数递减检测边际收益递减自动停止;Wink监控偏离行为注入课程纠正。
**痛点三:不能调用工具**
@@ -188,6 +189,52 @@ Prompt Optimizer(可选,需 Anthropic API key):
| 10-30B(qwen3:14b) | 可选计划 · 任务范围≤4文件 · 第2次失败触发搜索 |
| >30B(qwen3:72b) | 宽松策略 · 任务范围≤8文件 · 自动处理复杂任务 |
+### 原理八:EventBus 统一事件系统(v1.3.0)
+
+**理论来源**:Event Sourcing(Martin Fowler);CC 27 个 hook 事件(arXiv:2604.14228);Codified Context append-only 日志(arXiv:2602.20478)
+
+```
+所有模块通过 EventBus 发射事件:
+ 专家层 → emit("reading_file", {path}) → CLI 追加式渲染
+ 重试层 → emit("circuit_break", {reason}) → 用户可见
+ 搜索层 → emit("search_solution", {msg}) → 实时反馈
+
+append-only 日志支持 replay/时间旅行调试
+```
+
+### 原理九:错误策略路由 + 认知门控(v1.3.0)
+
+**理论来源**:Turn-Control Strategies(arXiv:2510.16786)动态预算比固定预算好 12-24%;SpecEyes(arXiv:2603.23483)认知门控
+
+```
+错误类型 → 专用重试序列:
+ syntax → [generator, verifier](1次后熔断)
+ import → [import_fixer, verifier](确定性修复,不调LLM)
+ runtime → [debugger, generator, verifier](先debug再修)
+ patch_apply → [locator, generator, verifier](重新定位)
+ assertion → [generator, verifier](2次后搜索)
+
+认知门控:patch行数持续递减 → 边际收益递减 → 自动停止
+Wink监控:scope_creep/repetitive_fix/patch_miss → 注入纠正hint
+```
+
+### 原理十:ToolGateway 权限隔离(v1.3.0)
+
+**理论来源**:CC 工具沙箱隔离(arXiv:2604.14228);deny-first 权限模型
+
+```
+专家层(只做生成,输出 patch 结构)
+ ↓
+ToolGateway(权限白名单 + 文件缓存 + 脏标记 + 事件emit)
+ ↓
+executor.py(实际执行 read_file / write_file / run_bash)
+
+每个专家只能调用白名单内的工具:
+ locator: [read_file, list_dir]
+ generator: [read_file](只读,不写)
+ verifier: [apply_patch, write_file, run_bash]
+```
+
---
## 功能特性
@@ -195,16 +242,21 @@ Prompt Optimizer(可选,需 Anthropic API key):
### 代码能力
- BM25 + 调用图两阶段定位,G3 隐藏依赖准确率 99.4%(论文验证)
- Generator 只改必要部分,从文件读 original,LLM 只生成 modified
-- 三阶段重试 + Reflection + Debug Subagent,不重复同样的错
+- 错误策略路由:按 error_type 切换重试序列,不重复同样的错
+- 认知门控:patch 行数递减检测边际收益递减,自动停止无效重试
+- Wink 自修复:检测 scope creep / 原地打转 / patch 失败,注入纠正
+- Speculative Prefetch:Locator 完成后后台预读文件,减少 Generator IO 等待
- Cross-Encoder 搜索结果重排(可选,`pip install kwcode[rerank]`)
### 多任务执行
- `/multi` 命令:串行(依赖链)+ 并行(独立任务)混合执行
- DAG 拓扑排序 + ThreadPoolExecutor 并行调度
+- Worktree 隔离:并行任务在独立工作目录执行,避免文件冲突
- 依赖上下文自动注入:前置任务结果传递给后续任务
### 流程控制
- `/plan 计划模式`:显示执行步骤+风险等级(High/Medium/Low),确认后才动文件
+- `Plan 自动触发`:hard 任务自动生成执行计划,不打断用户
- `Checkpoint 快照`:任务开始前自动备份,失败一键还原
- `KWCODE.md 项目规则`:写项目约定,按任务类型分段注入
@@ -214,10 +266,12 @@ Prompt Optimizer(可选,需 Anthropic API key):
- 非代码文件读取:PDF / Word / MD,BM25 匹配相关段落注入
### 搜索增强
-- 默认 DuckDuckGo(零配置)
+- SearchRouter 意图感知路由:按任务类型选最精准搜索源
+- 零 key 默认可用:arXiv API / Semantic Scholar / GitHub REST / PyPI JSON / Open-Meteo
+- 错误驱动搜索:按失败类型精准触发(import→立刻搜/runtime→debug后搜/assertion→2次后搜)
- 可选 SearXNG 自部署:`kwcode setup-search` 一键安装
+- 可选 Tavily key:通用搜索质量提升(1000次/月免费)
- 四级内容提取 + BM25 重排 + Cross-Encoder 精排
-- 意图感知:代码/论文/包/debug 自动优化搜索词
### Office 文档
- Excel / PPT / Word 生成
@@ -519,12 +573,21 @@ kaiwu/
| **AgentCoder** | Huang et al., EMNLP 2023 | 多专家分工验证,KWCode 的 Gate→专家流水线参考此分工模式 |
| **Agent Psychometrics** | arXiv:2604.00594, 2026 | 任务特征预测 agent 成功率,KWCode 的模型能力自适应参考此研究 |
| **TRUSTEE** | 2026 | 8B 模型可靠 tool calling 验证,KWCode 的 Gate 设计参考 |
+| **Dive into Claude Code** | arXiv:2604.14228, 2026 | ToolGateway 分层、EventBus 27 事件、5 层压缩管道、Worktree 隔离 |
+| **Wink** | arXiv:2602.17037, 2026 | Wink 自修复监控;失败类型分类(Drift/Reasoning/Tool) |
+| **ARCS** | arXiv:2504.20434, 2026 | 搜索前置于生成(retrieval-before-generation),按失败类型精准触发 |
+| **Speculative Actions** | arXiv:2510.04371 | Speculative Prefetch,下一步预测准确率 55% |
+| **SpecEyes** | arXiv:2603.23483 | 认知门控熔断,基于答案可分性检测边际收益递减 |
+| **OPENDEV** | arXiv:2603.05344 | 渐进上下文压缩,token 使用率分级触发 |
+| **Codified Context** | arXiv:2602.20478 | EventBus append-only 日志,跨 session 三层记忆 |
+| **Turn-Control Strategies** | arXiv:2510.16786 | 错误策略路由,动态预算比固定预算好 12-24% |
+| **CodeScout** | arXiv:2603.05744 | 问题陈述增强,输入质量是关键瓶颈 |
### 借鉴的开源项目
| 项目 | 借鉴点 |
|------|--------|
-| **Claude Code** (Anthropic) | CLAUDE.md 项目规则文件 → KWCode 的 KWCODE.md;Checkpoint 文件快照机制;/plan 计划模式 |
+| **Claude Code** (Anthropic) | CLAUDE.md 项目规则文件 → KWCode 的 KWCODE.md;Checkpoint 文件快照机制;/plan 计划模式;ToolGateway 权限隔离;EventBus 事件系统 |
| **Hermes** (Anthropic) | REPL 交互模式、MEMORY.md 记忆系统的交互设计 |
| **OpenHands V1** (All Hands AI) | Agent delegation 任务分解思路、Context Condensation 上下文压缩、LLM-based 集成测试回检 |
| **OpenCode** | 本地模型 coding agent 的产品形态参考;早期版本曾作为执行层底座探索 |
diff --git a/STATUS.md b/STATUS.md
index 9d6642d..9eaf681 100644
--- a/STATUS.md
+++ b/STATUS.md
@@ -7,9 +7,101 @@
---
-## 当前状态:v1.1.0 (2026-05-06)
+## 当前状态:v1.3.0 (2026-05-06)
-328/328 测试全绿(不含bench_tasks存根)。P0+P1+P2优化全部完成,spec关闭。
+357/357 测试全绿。v2 架构升级完成:EventBus + ToolGateway + 错误策略路由 + 认知门控 + 渐进压缩 + Plan自动触发 + Worktree隔离 + Speculative Prefetch + SearchRouter + Wink自修复 + 搜索层网络保护。
+
+### v1.3.0 新增:v2 架构升级(10 个模块)
+
+理论来源:Dive into Claude Code(arXiv:2604.14228) + Wink(arXiv:2602.17037) + ARCS(arXiv:2504.20434) + SpecEyes(arXiv:2603.23483) + OPENDEV(arXiv:2603.05344) + Turn-Control(arXiv:2510.16786)
+
+**模块1: EventBus 统一事件总线** (`core/event_bus.py`)
+- append-only 日志支持 replay/时间旅行调试
+- on/off/emit 三个核心方法,wildcard "*" 监听所有事件
+- CLI 接入追加式渲染(EVENT_ICONS 17种事件图标)
+
+**模块2: ToolGateway 工具权限层** (`tools/tool_gateway.py`)
+- 专家权限白名单(generator只读不写,verifier可写可执行)
+- 文件读缓存 + 脏标记(写后自动失效缓存)
+- 所有工具调用通过 EventBus 可观测
+
+**模块3: 错误策略路由** (`core/orchestrator.py` RETRY_STRATEGIES)
+- 按 error_type 切换重试序列(syntax/assertion/import/patch_apply/runtime/unknown)
+- import 错误:确定性修复器 `tools/import_fixer.py`(不调LLM)
+- _build_retry_hint() 按错误类型生成精准重试提示
+- _should_search() 按失败类型决定是否搜网络(不再统一 retry>=2 时搜)
+
+**模块4: 认知门控 CognitiveGate** (`core/cognitive_gate.py`)
+- patch 行数持续递减 → 边际收益递减 → 自动停止
+- 连续输出相同行数 → 原地打转 → 自动停止
+- 最后一次极小(≤3行) → 模型无从下手 → 自动停止
+
+**模块5: 上下文渐进压缩 GraduatedCompactor** (`core/context_pruner.py`)
+- Layer 1 (70%): 裁剪 tool 输出冗余(>500 token 提取关键词)
+- Layer 2 (85%): 复用 ContextPruner 压缩中间轮次
+- Layer 3 (95%): 摘要化早期对话,只保留关键决策
+
+**模块6: Plan 自动触发** (`core/orchestrator.py`)
+- hard 任务自动生成执行计划(不打断用户)
+- 低中风险直接执行,高风险暂停确认
+
+**模块7: Worktree 隔离** (`core/task_compiler.py` WorktreeManager)
+- Git 项目:git worktree 隔离
+- 非 Git 项目:tempdir + copytree
+- cleanup() 支持 merge 回主分支
+
+**模块8: Speculative Prefetch** (`experts/locator.py`)
+- Locator 完成后后台线程预读文件到内存
+- 减少 Generator 阶段 IO 等待
+
+**模块9: SearchRouter 意图感知搜索** (`search/search_router.py`)
+- 零 key 默认可用:arXiv / Semantic Scholar / GitHub REST / PyPI JSON / Open-Meteo
+- 按意图路由:research/code_solution/code_example/weather/library_doc/general
+- 可选 Tavily key 提升通用搜索质量
+- 错误驱动搜索接入 orchestrator
+
+**模块10: Wink 自修复监控** (`core/wink.py`)
+- scope_creep: easy任务定位>5文件 → 纠正
+- repetitive_fix: 同类错误≥2次 → 换思路
+- patch_miss: patch_apply失败 → 重新读文件
+- empty_output: Generator无输出 → 简化任务
+
+**搜索层网络保护补丁**
+- duckduckgo.py: DDG为主,SearXNG为可选增强,不自动拉起Docker
+- search_augmentor.py: 全局 try/except 保护,任何网络问题返回空
+- orchestrator.py: 搜索结果为空不阻塞,异常不中断重试流程
+- config.yaml: search_enabled 开关(环境变量 KWCODE_SEARCH_ENABLED)
+- 内网/离线用户设置 false 永远不触发网络请求
+
+### v1.2.0 新增:RIG侦察层(Project Map)
+
+理论来源:RIG(arXiv:2601.10112) + FastCode(arXiv:2603.01012) + CodeCompass工具采用率研究
+
+**RIG-1: export_rig() 仓库结构索引** (`ast_engine/graph_builder.py`)
+- 扫描全项目Python文件提取exports/imports(regex,零LLM)
+- 检测Flask/FastAPI路由装饰器 → api_routes
+- 匹配test_foo.py → foo.py → test_coverage
+- 扫描.js/.ts文件检测axios/fetch调用 → frontend_api_calls
+- 双文件输出:.kaiwu/rig.json(完整索引)+ .kaiwu/rig_summary.json(精简骨架<5KB)
+- 精简骨架动态截断文件列表,保证注入Gate/Locator不爆context
+
+**RIG-2: upstream_summary结构化** (`core/context.py` + `core/task_compiler.py`)
+- upstream_summary从str改为dict: {modified_files, diffs, new_symbols, broken_interfaces}
+- TaskCompiler自动提取上游patch的文件/diff/新符号,结构化传递给下游子任务
+- 新增_format_upstream_text()将结构化dict转为LLM可读文本注入prompt
+- 下游子任务Locator可直接读取"哪些文件被改了",不靠模型猜
+
+**RIG-3: ConsistencyChecker前后端一致性检查** (`experts/consistency_checker.py`)
+- 基于rig.json做确定性集合对比(不调LLM)
+- 输出backend_only/frontend_only/matched不一致清单
+- check_with_details()带文件位置信息,可直接作为子任务输入
+- format_for_subtask()生成可注入Generator的文本
+
+**RIG-4: Gate/Locator prompt显式引导查rig** (`core/gate.py` + `experts/locator.py`)
+- Gate prompt新增显式引导:"优先参考.kaiwu/rig.json理解项目结构"
+- Locator prompt新增{rig_context}占位符
+- _load_rig_context()读取rig_summary.json(不是完整rig.json),注入路由/前端调用/测试覆盖摘要
+- 解决CodeCompass发现的工具采用率42%问题:模型不查图的根因是prompt没引导
### v1.1.0 新增:P0+P1+P2 全量优化
@@ -162,7 +254,9 @@
| 搜索重构测试 | 19 | PASS |
| 意图搜索测试 | 19 | PASS |
| E2E 真实模型 | 17 | PASS |
-| **合计** | **282** | **全绿** |
+| RIG模块测试 | 29 | PASS |
+| TaskCompiler测试 | 12 | PASS |
+| **合计** | **357** | **全绿** |
### 待做
@@ -191,29 +285,35 @@ kwcode/
├── STATUS.md
└── kaiwu/
├── cli/
- │ ├── main.py # REPL + spinner + 结果摘要 + 重影Header + setup-search
+ │ ├── main.py # REPL + EventBus追加式渲染 + spinner + 结果摘要
│ ├── status_bar.py # 状态栏(4档自适应) + TokPerSecEstimator
│ └── onboarding.py # 首次启动引导
├── core/
+ │ ├── event_bus.py # [v1.3] 统一事件总线(append-only日志+replay)
+ │ ├── cognitive_gate.py # [v1.3] 认知门控(patch行数递减检测)
+ │ ├── wink.py # [v1.3] Wink自修复监控(偏离检测+纠正注入)
│ ├── gate.py # LLM任务分类 → 专家知识叠加
- │ ├── orchestrator.py # 确定性流水线 + KWCODE.md注入 + Checkpoint + ValueTracker
+ │ ├── orchestrator.py # 确定性流水线 + 错误策略路由 + Plan自动触发
│ ├── context.py # TaskContext数据类
+ │ ├── task_compiler.py # DAG调度器 + WorktreeManager隔离
│ ├── planner.py # /plan计划模式 + 风险评估
│ ├── checkpoint.py # 文件快照(git stash/文件复制)
│ ├── kwcode_md.py # KWCODE.md分段加载+注入
│ ├── model_capability.py # 模型三档自适应(SMALL/MEDIUM/LARGE)
- │ ├── context_pruner.py # 上下文压缩(纯算法,<10ms)
+ │ ├── context_pruner.py # 上下文压缩 + GraduatedCompactor 3层渐进压缩
│ ├── network.py # 网络探测+代理配置
│ └── sysinfo.py # 系统信息+VRAM监控
├── experts/
- │ ├── locator.py # BM25+调用图定位 + DocReader注入
+ │ ├── locator.py # BM25+调用图定位 + DocReader注入 + Speculative Prefetch
│ ├── generator.py # 代码生成(original从文件读,LLM只写modified)
│ ├── verifier.py # 语法检查 + pytest
- │ ├── search_augmentor.py # 搜索增强 + BM25重排
+ │ ├── search_augmentor.py # 搜索增强 + BM25重排 + 网络保护
+ │ ├── consistency_checker.py # 前后端接口一致性检查(确定性,不调LLM)
│ ├── chat_expert.py # 聊天(搜索门控:follow-up/推理不搜)
│ └── office_handler.py # Office文档生成
├── search/
- │ ├── duckduckgo.py # SearXNG+DDG并行搜索
+ │ ├── search_router.py # [v1.3] 意图感知搜索路由(arxiv/S2/GitHub/PyPI/Open-Meteo)
+ │ ├── duckduckgo.py # DDG主+SearXNG可选 + search_enabled开关
│ ├── extraction_pipeline.py # 四级内容提取
│ ├── intent_classifier.py # 意图感知(5类+LLM fallback)
│ ├── query_generator.py # 按意图生成搜索词
@@ -228,6 +328,6 @@ kwcode/
├── ast_engine/ # tree-sitter AST + 调用图(SQLite)
├── mcp/ # MCP Router
├── llm/ # Ollama + llama.cpp双后端
- ├── tools/ # 5个确定性工具
- └── tests/ # 282个测试
+ ├── tools/ # 5个确定性工具 + ToolGateway + import_fixer
+ └── tests/ # 357个测试
```
diff --git a/kaiwu/cli/main.py b/kaiwu/cli/main.py
index 5ff0892..12b05e5 100644
--- a/kaiwu/cli/main.py
+++ b/kaiwu/cli/main.py
@@ -52,6 +52,46 @@ console = Console()
# ── Status display ────────────────────────────────────────────
+# EventBus event icons (追加式渲染,替代单行spinner)
+EVENT_ICONS = {
+ "expert_start": ("●", "blue"),
+ "reading_file": (" 📄", "dim"),
+ "file_written": (" ✓", "green"),
+ "applying_patch": (" →", "yellow"),
+ "patch_result": (" ✓", "green"),
+ "generator_patch": (" →", "yellow"),
+ "test_pass": (" ✓", "green"),
+ "test_fail": (" ✗", "red"),
+ "retry": ("🔄", "yellow"),
+ "circuit_break": ("⛔", "red"),
+ "scope_narrow": ("🎯", "cyan"),
+ "search_start": ("🌐", "blue"),
+ "search_solution": ("💡", "cyan"),
+ "plan_generated": ("📋", "blue"),
+ "pre_compact": ("📦", "dim"),
+ "wink_intervene": ("🔧", "yellow"),
+}
+
+# 阶段级事件(换行显示)
+_PHASE_EVENTS = {"expert_start", "retry", "circuit_break", "plan_generated", "wink_intervene"}
+
+
+def _eventbus_cli_handler(event: str, payload: dict):
+ """EventBus 全局 CLI handler:追加式渲染事件到终端。"""
+ icon_info = EVENT_ICONS.get(event)
+ if not icon_info:
+ return
+ icon, color = icon_info
+ detail = payload.get("path") or payload.get("msg") or payload.get("cmd", "")
+ if not detail:
+ return
+ if event in _PHASE_EVENTS:
+ console.print()
+ console.print(f"[bold {color}]{icon} {detail}[/bold {color}]")
+ else:
+ console.print(f"[{color}]{icon} {detail}[/{color}]")
+
+
# Spinner stage mapping (internal stage → user-friendly description)
_SPINNER_STAGES = {
"gate": "分析任务...",
@@ -113,24 +153,24 @@ def _build_pipeline(model_path, ollama_url, ollama_model, project_root, verbose)
net = detect_network()
if net["china"]:
proxy_hint = f"代理: {net['proxy']}" if net["proxy"] else "配置代理可加速: export KAIWU_PROXY=http://..."
- console.print(f" [yellow][网络] 国内网络,搜索已启用 Bing fallback。{proxy_hint}[/yellow]")
+ console.print(f" [yellow][网络] 国内网络。{proxy_hint}[/yellow]")
- # SearXNG预检测+自动启动(在pipeline构建时完成,不阻塞用户首次提问)
- from kaiwu.search.duckduckgo import _searxng_available, _try_start_searxng, _get_searxng_url
+ # SearXNG预检测(不自动拉起Docker,静默降级)
+ from kaiwu.search.duckduckgo import _searxng_available, _get_searxng_url, _is_search_enabled
import kaiwu.search.duckduckgo as _search_mod
- if _search_mod._searxng_ok is None:
+ if not _is_search_enabled():
+ console.print(f" [dim][搜索] 已禁用(search_enabled=false)[/dim]")
+ elif _search_mod._searxng_ok is None:
searxng_url = _get_searxng_url()
if _searxng_available(searxng_url):
_search_mod._searxng_ok = True
console.print(f" [green][搜索] SearXNG 就绪[/green]")
else:
- console.print(f" [yellow][搜索] SearXNG 未就绪,尝试自动启动...[/yellow]")
- if _try_start_searxng():
- _search_mod._searxng_ok = True
- console.print(f" [green][搜索] SearXNG 已自动启动[/green]")
+ _search_mod._searxng_ok = False
+ if _search_mod.HAS_DDGS:
+ console.print(f" [dim][搜索] SearXNG 不可用,使用 DuckDuckGo[/dim]")
else:
- _search_mod._searxng_ok = False
- console.print(f" [yellow][搜索] SearXNG 不可用,降级到 DuckDuckGo[/yellow]")
+ console.print(f" [dim][搜索] 无可用搜索引擎,搜索增强已禁用[/dim]")
# Load API key from config
from kaiwu.cli.onboarding import load_config as _load_cfg
@@ -190,6 +230,10 @@ def _build_pipeline(model_path, ollama_url, ollama_model, project_root, verbose)
debug_subagent=debug_subagent,
vision_expert=vision_expert,
)
+
+ # Wire EventBus CLI handler
+ orchestrator.bus.on("*", _eventbus_cli_handler)
+
# Wire circular reference: ABTester needs orchestrator for backtest
ab_tester.orchestrator = orchestrator
diff --git a/kaiwu/core/cognitive_gate.py b/kaiwu/core/cognitive_gate.py
new file mode 100644
index 0000000..073562e
--- /dev/null
+++ b/kaiwu/core/cognitive_gate.py
@@ -0,0 +1,73 @@
+"""
+CognitiveGate: 认知门控熔断,检测边际收益递减。
+
+检测 Generator 输出是否在边际收益递减:
+- patch 行数持续递减 → 模型已无有效修复方向 → 停止重试
+- 替代固定计数熔断,更精确地判断何时该停
+
+理论来源:
+- CC Diminishing Returns Detection(CC Source Analysis 2026)
+- SpecEyes 认知门控(arXiv:2603.23483)
+- Speculative Actions(arXiv:2510.04371)
+"""
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class CognitiveGate:
+ """
+ 认知门控:基于 patch 行数变化趋势判断是否应停止重试。
+ 比 token 数更精确——patch 行数直接反映修复意图变化。
+ """
+
+ def __init__(self, window: int = 3, threshold: float = 0.3):
+ """
+ Args:
+ window: 观察窗口大小(需要多少次记录才开始判断)
+ threshold: 递减阈值(最后一次 <= 第一次 * threshold 时触发)
+ """
+ self.window = window
+ self.threshold = threshold
+ self._patch_lines: list[int] = []
+
+ def record(self, patches: list[dict]) -> None:
+ """记录一次 Generator 输出的 patch 总行数。"""
+ total = sum(len(p.get("modified", "").splitlines()) for p in patches)
+ self._patch_lines.append(total)
+
+ def should_stop(self) -> tuple[bool, str]:
+ """
+ 判断是否应停止重试。
+ Returns:
+ (should_stop, reason) — reason 为空字符串表示不停止
+ """
+ if len(self._patch_lines) < self.window:
+ return False, ""
+
+ recent = self._patch_lines[-self.window:]
+
+ # 持续递减且降幅超过阈值
+ if all(recent[i] > recent[i + 1] for i in range(len(recent) - 1)):
+ if recent[-1] <= recent[0] * self.threshold:
+ return True, f"patch行数持续递减 {recent},边际收益递减"
+
+ # 最后一次极小(模型已无从下手)
+ if recent[-1] <= 3 and len(self._patch_lines) >= 2:
+ return True, f"patch行数降至 {recent[-1]} 行,停止重试"
+
+ # 连续输出相同行数(原地打转)
+ if len(set(recent)) == 1 and len(self._patch_lines) >= self.window:
+ return True, f"patch行数连续 {self.window} 次相同({recent[-1]}行),原地打转"
+
+ return False, ""
+
+ def reset(self):
+ """重置状态(新任务开始时调用)。"""
+ self._patch_lines.clear()
+
+ @property
+ def history(self) -> list[int]:
+ """返回 patch 行数历史记录。"""
+ return list(self._patch_lines)
diff --git a/kaiwu/core/context_pruner.py b/kaiwu/core/context_pruner.py
index 2f7d044..212c5b2 100644
--- a/kaiwu/core/context_pruner.py
+++ b/kaiwu/core/context_pruner.py
@@ -225,3 +225,119 @@ def _extract_code_blocks(text: str) -> str:
for block in blocks[:3]:
result_parts.append(f"```\n{block.rstrip()}\n```")
return "\n\n".join(result_parts)
+
+
+# ── GraduatedCompactor: 3层渐进压缩 ──
+# 理论来源:CC 5层压缩管道(arXiv:2604.14228);OPENDEV Adaptive Compaction(arXiv:2603.05344)
+
+class GraduatedCompactor:
+ """
+ 3 层渐进压缩,按 token 使用率分级触发。
+ Layer 1 (70%):裁剪 tool 输出冗余
+ Layer 2 (85%):压缩中间轮次 assistant 输出
+ Layer 3 (95%):摘要化早期对话,只保留关键决策
+ """
+
+ def __init__(self, max_tokens: int = 8192):
+ self._pruner = ContextPruner(max_tokens=max_tokens)
+ self.max_tokens = max_tokens
+
+ def compress(self, messages: list[dict], usage_ratio: float = 0.0,
+ bus=None) -> list[dict]:
+ """
+ 按 token 使用率分级压缩。
+ Args:
+ messages: 消息列表
+ usage_ratio: 当前 token 使用率 (0.0~1.0),0 表示自动计算
+ bus: EventBus 实例(可选,用于发射压缩事件)
+ """
+ if not messages:
+ return messages
+
+ # 自动计算使用率
+ if usage_ratio <= 0:
+ total = sum(_count_tokens(m.get("content", "")) for m in messages)
+ usage_ratio = total / max(self.max_tokens, 1)
+
+ if usage_ratio < 0.70:
+ return messages
+
+ layer = self._layer(usage_ratio)
+ if bus:
+ bus.emit("pre_compact", {"ratio": usage_ratio, "layer": layer})
+
+ if usage_ratio < 0.85:
+ result = self._layer1_trim_tools(messages)
+ elif usage_ratio < 0.95:
+ result = self._layer2_compress_middle(messages)
+ else:
+ result = self._layer3_summarize_early(messages)
+
+ if bus:
+ orig = sum(_count_tokens(m.get("content", "")) for m in messages)
+ new = sum(_count_tokens(m.get("content", "")) for m in result)
+ bus.emit("post_compact", {"saved_tokens": orig - new, "layer": layer})
+
+ return result
+
+ def _layer(self, ratio: float) -> int:
+ return 1 if ratio < 0.85 else (2 if ratio < 0.95 else 3)
+
+ def _layer1_trim_tools(self, messages: list[dict]) -> list[dict]:
+ """Layer 1: 裁剪 tool 输出冗余(>500 token 的 tool 输出提取关键词)。"""
+ result = []
+ for msg in messages:
+ if msg.get("role") == "tool" and _count_tokens(msg.get("content", "")) > 500:
+ content = msg.get("content", "")
+ # 保护代码块
+ if _has_code_block(content):
+ code_only = _extract_code_blocks(content)
+ if code_only:
+ result.append({**msg, "content": code_only})
+ continue
+ kw = _extract_keywords(content)
+ if kw:
+ result.append({**msg, "content": kw})
+ else:
+ tokens = _count_tokens(content)
+ result.append({**msg, "content": f"[tool output masked, {tokens} tokens]"})
+ else:
+ result.append(msg)
+ return result
+
+ def _layer2_compress_middle(self, messages: list[dict]) -> list[dict]:
+ """Layer 2: 复用 ContextPruner 逻辑压缩中间轮次。"""
+ return self._pruner.prune(messages)
+
+ def _layer3_summarize_early(self, messages: list[dict]) -> list[dict]:
+ """Layer 3: 摘要化早期对话,只保留关键决策。"""
+ if len(messages) < 6:
+ return self._layer2_compress_middle(messages)
+
+ # 保留头部(system + 首轮)
+ head = []
+ rest = list(messages)
+ if rest and rest[0].get("role") == "system":
+ head.append(rest.pop(0))
+ if rest and rest[0].get("role") == "user":
+ head.append(rest.pop(0))
+ if rest and rest[0].get("role") == "assistant":
+ head.append(rest.pop(0))
+
+ # 保留最近4条消息
+ if len(rest) <= 4:
+ return head + rest
+ recent = rest[-4:]
+ middle = rest[:-4]
+
+ # 中间部分提取关键词摘要
+ middle_keywords = []
+ for m in middle:
+ if m.get("role") in ("assistant", "tool"):
+ kw = _extract_keywords(m.get("content", ""))
+ if kw:
+ middle_keywords.append(kw.replace("[摘要] ", ""))
+ summary_text = " | ".join(middle_keywords[:20]) if middle_keywords else "[早期对话已压缩]"
+ summary = {"role": "system", "content": f"[早期对话摘要] {summary_text}"}
+
+ return head + [summary] + recent
diff --git a/kaiwu/core/event_bus.py b/kaiwu/core/event_bus.py
new file mode 100644
index 0000000..87ef600
--- /dev/null
+++ b/kaiwu/core/event_bus.py
@@ -0,0 +1,76 @@
+"""
+EventBus: 统一事件总线(Event Sourcing 模式)。
+append-only 日志支持 replay/调试,替代分散的 on_status 回调。
+
+理论来源:
+- Event Sourcing(Martin Fowler)
+- CC 27 个 hook 事件(arXiv:2604.14228)
+- Codified Context append-only 日志(arXiv:2602.20478)
+"""
+
+from collections import defaultdict
+from typing import Callable
+import time
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class EventBus:
+ """
+ 统一事件总线。
+ - on(event, handler) 注册监听
+ - emit(event, payload) 发射事件
+ - replay() 返回完整事件日志
+ """
+
+ def __init__(self):
+ self._handlers: dict[str, list[Callable]] = defaultdict(list)
+ self._wildcard: list[Callable] = []
+ self._log: list[dict] = []
+
+ def on(self, event: str, handler: Callable):
+ """注册事件处理器。event="*" 监听所有事件。"""
+ if event == "*":
+ self._wildcard.append(handler)
+ else:
+ self._handlers[event].append(handler)
+
+ def off(self, event: str, handler: Callable):
+ """移除事件处理器。"""
+ if event == "*":
+ try:
+ self._wildcard.remove(handler)
+ except ValueError:
+ pass
+ else:
+ try:
+ self._handlers[event].remove(handler)
+ except ValueError:
+ pass
+
+ def emit(self, event: str, payload: dict | None = None):
+ """发射事件,通知所有监听器,同时记录到日志。"""
+ payload = payload or {}
+ entry = {"t": time.time(), "event": event, **payload}
+ self._log.append(entry)
+ for h in self._handlers.get(event, []) + self._wildcard:
+ try:
+ h(event, payload)
+ except Exception as e:
+ logger.debug("EventBus handler error [%s]: %s", event, e)
+
+ def replay(self) -> list[dict]:
+ """返回完整事件日志副本。"""
+ return list(self._log)
+
+ def clear_log(self):
+ """清空事件日志(不影响已注册的handler)。"""
+ self._log.clear()
+
+ def handler_count(self) -> int:
+ """返回已注册handler总数。"""
+ total = len(self._wildcard)
+ for handlers in self._handlers.values():
+ total += len(handlers)
+ return total
diff --git a/kaiwu/core/orchestrator.py b/kaiwu/core/orchestrator.py
index dede967..c52d1a0 100644
--- a/kaiwu/core/orchestrator.py
+++ b/kaiwu/core/orchestrator.py
@@ -10,6 +10,9 @@ import threading
from typing import Optional
from kaiwu.core.context import TaskContext
+from kaiwu.core.event_bus import EventBus
+from kaiwu.core.cognitive_gate import CognitiveGate
+from kaiwu.core.wink import WinkMonitor
from kaiwu.experts.locator import LocatorExpert
from kaiwu.experts.generator import GeneratorExpert
from kaiwu.experts.verifier import VerifierExpert
@@ -40,6 +43,42 @@ EXPERT_SEQUENCES = {
"vision": ["vision"],
}
+# ── 错误策略路由:按 error_type 切换重试序列 ──
+# 理论来源:Turn-Control Strategies(arXiv:2510.16786);Wink(arXiv:2602.17037)
+RETRY_STRATEGIES = {
+ "syntax": {
+ "sequence": ["generator", "verifier"],
+ "hint": "只修复语法错误,错误在 {error_file}:{error_line},不改其他逻辑",
+ "search": False,
+ },
+ "assertion": {
+ "sequence": ["generator", "verifier"],
+ "hint": "测试期望:{error_message},只改让测试通过的最小代码",
+ "search": False,
+ },
+ "import": {
+ "sequence": ["import_fixer", "verifier"],
+ "hint": "",
+ "search": True,
+ },
+ "patch_apply": {
+ "sequence": ["locator", "generator", "verifier"],
+ "hint": "重新读取文件最新内容,不要使用缓存的 original",
+ "search": False,
+ },
+ "runtime": {
+ "sequence": ["debugger", "generator", "verifier"],
+ "hint": "",
+ "search": False,
+ },
+ "unknown": {
+ "sequence": ["generator", "verifier"],
+ "hint": "缩小修改范围,只改最小可疑函数",
+ "search": False,
+ "scope_narrow": True,
+ },
+}
+
class PipelineOrchestrator:
"""Deterministic expert pipeline orchestrator."""
@@ -62,6 +101,7 @@ class PipelineOrchestrator:
chat_expert: ChatExpert | None = None,
debug_subagent=None,
vision_expert=None,
+ bus: EventBus | None = None,
):
self.locator = locator
self.generator = generator
@@ -79,6 +119,9 @@ class PipelineOrchestrator:
self.debug_subagent = debug_subagent
self._value_tracker = ValueTracker()
self._notifier = FlywheelNotifier()
+ self.bus = bus or EventBus()
+ self._wink = WinkMonitor()
+ self._cognitive_gate = CognitiveGate()
def run(
self,
@@ -240,11 +283,35 @@ class PipelineOrchestrator:
# codegen任务如果涉及实时数据,首次就触发搜索(不等失败重试)
if expert_type == "codegen" and not no_search and self._needs_realtime_data(user_input):
- self._emit(on_status, "search", "检测到实时数据需求,预搜索...")
- ctx.search_results = self.search_augmentor.search(ctx)
- ctx.search_triggered = True
- if ctx.search_results:
- self._emit(on_status, "search_done", f"搜索完成,注入{len(ctx.search_results)}字参考信息")
+ try:
+ self._emit(on_status, "search", "检测到实时数据需求,预搜索...")
+ results = self.search_augmentor.search(ctx)
+ if results:
+ ctx.search_results = results
+ ctx.search_triggered = True
+ self._emit(on_status, "search_done", f"搜索完成,注入{len(results)}字参考信息")
+ except Exception as e:
+ logger.debug("Pre-search failed (网络保护,不阻塞): %s", e)
+
+ # ── Plan 自动触发:hard 任务自动生成计划(不打断用户)──
+ if (gate_result.get("difficulty") == "hard"
+ and expert_type not in ("chat", "office", "vision")
+ and not ctx.subtask_results):
+ try:
+ from kaiwu.core.planner import Planner
+ from kaiwu.memory import pattern_md
+ planner = Planner(
+ locator=self.locator,
+ pattern_md_module=pattern_md,
+ llm=self.generator.llm,
+ )
+ plan = planner.generate_plan_steps(user_input, gate_result, project_root)
+ if plan and len(plan) > 1:
+ ctx.execution_plan = plan
+ self._emit(on_status, "plan_generated", f"自动生成 {len(plan)} 步计划")
+ self.bus.emit("plan_generated", {"steps": len(plan), "msg": f"生成 {len(plan)} 步计划"})
+ except Exception as e:
+ logger.debug("Auto-plan failed (non-blocking): %s", e)
# ── Checkpoint: snapshot before execution (skip in multi-task to avoid race) ──
checkpoint = Checkpoint(project_root)
@@ -264,10 +331,14 @@ class PipelineOrchestrator:
self._emit(on_status, "low_confidence",
f"任务分类置信度较低({confidence:.0%}),减少重试次数")
+ # ── CognitiveGate reset for this task ──
+ self._cognitive_gate.reset()
+
while ctx.retry_count < max_retries:
# Watchdog check: abort if task exceeded timeout
if _watchdog_triggered.is_set():
self._emit(on_status, "watchdog", f"任务超时({TASK_TIMEOUT_S}s),强制终止")
+ self.bus.emit("circuit_break", {"msg": f"任务超时({TASK_TIMEOUT_S}s)"})
break
success = self._run_sequence(sequence, ctx, on_status)
@@ -313,6 +384,15 @@ class PipelineOrchestrator:
# Save failure info for retry strategy
ctx.previous_failure = error_detail
+ # ── CognitiveGate: 检测边际收益递减 ──
+ if ctx.generator_output:
+ self._cognitive_gate.record(ctx.generator_output.get("patches", []))
+ cg_stop, cg_reason = self._cognitive_gate.should_stop()
+ if cg_stop:
+ self._emit(on_status, "circuit_break", cg_reason)
+ self.bus.emit("circuit_break", {"msg": cg_reason})
+ break
+
# ── Circuit breaker: same error_type streak ──
current_error_type = ""
if ctx.verifier_output:
@@ -329,18 +409,34 @@ class PipelineOrchestrator:
# Fast circuit break: syntax errors don't improve with retries
if current_error_type == "syntax" and ctx.retry_count >= 1:
self._emit(on_status, "circuit_break", "语法错误重试无效,模型能力不足以完成此任务")
+ self.bus.emit("circuit_break", {"msg": "syntax error"})
break
- # Fast circuit break: missing imports need user action
+ # Fast circuit break: missing imports — try import_fixer first
if current_error_type == "import":
- missing = ctx.verifier_output.get("error_message", "") if ctx.verifier_output else ""
- self._emit(on_status, "circuit_break", f"缺少依赖:{missing},请先安装")
- break
+ fixed = self._try_import_fix(ctx, on_status)
+ if not fixed:
+ missing = ctx.verifier_output.get("error_message", "") if ctx.verifier_output else ""
+ self._emit(on_status, "circuit_break", f"缺少依赖:{missing},请先安装")
+ self.bus.emit("circuit_break", {"msg": f"import: {missing}"})
+ break
+ # import_fixer succeeded, continue retry loop
# Hard circuit break: same error type 3 times in a row
if ctx._error_type_streak["count"] >= 3:
self._emit(on_status, "circuit_break",
f"同类错误({current_error_type})连续{ctx._error_type_streak['count']}次,停止重试")
+ self.bus.emit("circuit_break", {"msg": f"{current_error_type} x{ctx._error_type_streak['count']}"})
break
+ # ── Wink 自修复:检测偏离并注入纠正 ──
+ wink_hint = self._wink.check(ctx, self.bus)
+
+ # ── 错误策略路由:按 error_type 切换重试序列 ──
+ retry_strategy = RETRY_STRATEGIES.get(current_error_type, RETRY_STRATEGIES["unknown"])
+ sequence = retry_strategy["sequence"]
+ ctx.retry_hint = self._build_retry_hint(ctx, current_error_type)
+ if wink_hint:
+ ctx.retry_hint = (ctx.retry_hint + "\n" + wink_hint).strip() if ctx.retry_hint else wink_hint
+
# ── Scope narrowing: on 2nd failure, reduce to first file+function ──
if ctx.retry_count == 2 and ctx.locator_output:
files = ctx.locator_output.get("relevant_files", [])
@@ -357,8 +453,10 @@ class PipelineOrchestrator:
files[0]: ctx.relevant_code_snippets.get(files[0], "")
}
self._emit(on_status, "scope_narrow", f"缩小范围:只修 {funcs[0]}()")
+ self.bus.emit("scope_narrow", {"msg": f"只修 {funcs[0]}()"})
self._emit(on_status, "retry", f"第{ctx.retry_count}次尝试失败:{error_detail[:100]}")
+ self.bus.emit("retry", {"count": ctx.retry_count, "error": error_detail[:100]})
# Reflection before 2nd retry: ask LLM why the patch failed
if ctx.retry_count == 1 and ctx.verifier_output and ctx.generator_output:
@@ -371,16 +469,22 @@ class PipelineOrchestrator:
# Set retry strategy: each retry uses a different approach
ctx.retry_strategy = ctx.retry_count # 0→1→2
- # Trigger SearchAugmentor: failed 2x OR hard task failed 1x
- should_search = (
- ctx.retry_count >= 2
- or (gate_result.get("difficulty") == "hard" and ctx.retry_count >= 1)
- )
- if should_search and not ctx.search_triggered and not no_search:
- self._emit(on_status, "search", "触发搜索增强...")
- ctx.search_results = self.search_augmentor.search(ctx)
- ctx.search_triggered = True
- self._emit(on_status, "search_done", f"搜索完成,注入{len(ctx.search_results)}字参考信息")
+ # ── 错误驱动搜索:按失败类型决定是否搜索(网络保护:异常不阻塞)──
+ if self._should_search(current_error_type, ctx.retry_count) and not ctx.search_triggered and not no_search:
+ try:
+ self._emit(on_status, "search", f"搜索 {current_error_type} 解法...")
+ self.bus.emit("search_start", {"msg": f"搜索 {current_error_type} 解法"})
+ results = self.search_augmentor.search(ctx)
+ if results: # 搜到才用,搜不到继续原流程
+ ctx.search_results = results
+ ctx.search_triggered = True
+ self._emit(on_status, "search_done", f"搜索完成,注入{len(results)}字参考信息")
+ self.bus.emit("search_solution", {"msg": "找到参考方案"})
+ else:
+ ctx.search_triggered = True # 标记已尝试,不重复触发
+ except Exception as e:
+ logger.debug("Search failed (网络保护,不阻塞): %s", e)
+ ctx.search_triggered = True # 失败也标记,避免循环重试搜索
# Reset expert outputs for retry (RED-3: fresh context each attempt)
ctx.locator_output = None
@@ -647,3 +751,59 @@ class PipelineOrchestrator:
)
except Exception as e:
logger.debug("Reflection persistence failed (non-blocking): %s", e)
+
+ def _build_retry_hint(self, ctx: TaskContext, error_type: str) -> str:
+ """按错误类型生成重试提示,注入 Generator prompt。"""
+ strategy = RETRY_STRATEGIES.get(error_type, RETRY_STRATEGIES["unknown"])
+ template = strategy.get("hint", "")
+ if not template:
+ return ""
+ v = ctx.verifier_output or {}
+ try:
+ return template.format(
+ error_file=v.get("error_file", ""),
+ error_line=v.get("error_line", 0),
+ error_message=v.get("error_message", ""),
+ )
+ except (KeyError, ValueError):
+ return template
+
+ def _should_search(self, error_type: str, retry_count: int) -> bool:
+ """按失败类型决定是否搜网络,不是统一在 retry>=2 时搜。"""
+ strategy = RETRY_STRATEGIES.get(error_type, RETRY_STRATEGIES["unknown"])
+ # import 错误:立刻搜
+ if strategy.get("search") and retry_count >= 1:
+ return True
+ # runtime 错误:debug 一次后仍失败才搜
+ if error_type == "runtime" and retry_count >= 2:
+ return True
+ # assertion 连续 2 次同样错误:搜最优解法
+ if error_type == "assertion" and retry_count >= 2:
+ return True
+ # 通用 fallback:第3次失败搜
+ if retry_count >= 3:
+ return True
+ return False
+
+ def _try_import_fix(self, ctx: TaskContext, on_status) -> bool:
+ """尝试用 import_fixer 确定性修复缺失 import(不调 LLM)。"""
+ try:
+ from kaiwu.tools.import_fixer import fix_missing_import
+ v = ctx.verifier_output or {}
+ error_msg = v.get("error_message", "")
+ error_file = v.get("error_file", "")
+ if not error_file or not error_msg:
+ return False
+ content = self.tools.read_file(error_file)
+ if content.startswith("[ERROR]"):
+ return False
+ fixed = fix_missing_import(content, error_msg)
+ if fixed and fixed != content:
+ self.tools.write_file(error_file, fixed)
+ self._emit(on_status, "import_fix", f"自动修复 import: {error_file}")
+ self.bus.emit("file_written", {"path": error_file})
+ return True
+ return False
+ except Exception as e:
+ logger.debug("Import fixer failed (non-blocking): %s", e)
+ return False
diff --git a/kaiwu/core/task_compiler.py b/kaiwu/core/task_compiler.py
index 711843a..e330e12 100644
--- a/kaiwu/core/task_compiler.py
+++ b/kaiwu/core/task_compiler.py
@@ -8,6 +8,7 @@ Each task gets its own TaskContext (RED-3: independent context).
"""
import logging
+import re
import time
from collections import deque
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -135,11 +136,13 @@ class TaskCompiler:
user_input = task_def["input"]
# Inject dependency context: append completed task outputs to input
+ upstream_dict: dict = {}
deps = task_def.get("depends_on", [])
if deps:
- dep_context = self._build_dependency_context(deps, completed)
- if dep_context:
- user_input = f"{user_input}\n\n[前置任务结果]\n{dep_context}"
+ upstream_dict = self._build_dependency_context(deps, completed)
+ if upstream_dict.get("modified_files"):
+ upstream_text = self._format_upstream_text(upstream_dict)
+ user_input = f"{user_input}\n\n[前置任务结果]\n{upstream_text}"
# Gate classification (use override or auto-classify)
expert_type = task_def.get("expert_type")
@@ -154,7 +157,7 @@ class TaskCompiler:
logger.info("[task_compiler] Executing task %s: %s", task_id, user_input[:50])
- return self.orchestrator.run(
+ result = self.orchestrator.run(
user_input=user_input,
gate_result=gate_result,
project_root=self.project_root,
@@ -162,22 +165,74 @@ class TaskCompiler:
skip_checkpoint=True, # 问题4修复:多任务时跳过子任务级checkpoint,避免并行竞态
)
+ # Store structured upstream_summary on the context for downstream access
+ if result.get("context") and upstream_dict:
+ result["context"].upstream_summary = upstream_dict
+
+ return result
+
@staticmethod
- def _build_dependency_context(dep_ids: list[str], completed: dict) -> str:
- """Build context string from completed dependency results."""
- parts = []
+ def _build_dependency_context(dep_ids: list[str], completed: dict) -> dict:
+ """Build structured context dict from completed dependency results."""
+ modified_files: list[str] = []
+ diffs: dict[str, str] = {}
+ new_symbols: list[str] = []
+ broken_interfaces: list[str] = []
+
for dep_id in dep_ids:
result = completed.get(dep_id)
if not result or not result.get("context"):
continue
ctx = result["context"]
- # Extract explanation from generator output
gen = ctx.generator_output
- if gen and gen.get("explanation"):
- parts.append(f"任务{dep_id}: {gen['explanation'][:200]}")
- elif gen and gen.get("patches"):
- files = [p.get("file", "") for p in gen["patches"]]
- parts.append(f"任务{dep_id}: 修改了 {', '.join(files)}")
+ if not gen or not gen.get("patches"):
+ continue
+ for patch in gen["patches"]:
+ file_path = patch.get("file", "")
+ if not file_path:
+ continue
+ if file_path not in modified_files:
+ modified_files.append(file_path)
+ # Collect diff (truncate to 200 lines)
+ modified_code = patch.get("modified", "")
+ if modified_code and file_path not in diffs:
+ lines = modified_code.splitlines()
+ if len(lines) > 200:
+ lines = lines[:200]
+ lines.append("... (truncated)")
+ diffs[file_path] = "\n".join(lines)
+ # Extract new function/method symbols from modified code
+ if modified_code:
+ for match in re.finditer(r"def\s+(\w+)\s*\(", modified_code):
+ symbol = match.group(1)
+ if symbol not in new_symbols:
+ new_symbols.append(symbol)
+
+ return {
+ "modified_files": modified_files,
+ "diffs": diffs,
+ "new_symbols": new_symbols,
+ "broken_interfaces": broken_interfaces,
+ }
+
+ @staticmethod
+ def _format_upstream_text(upstream_dict: dict) -> str:
+ """Convert structured upstream dict to readable text for LLM injection."""
+ parts = []
+ modified = upstream_dict.get("modified_files", [])
+ if modified:
+ parts.append(f"修改文件: {', '.join(modified)}")
+ new_symbols = upstream_dict.get("new_symbols", [])
+ if new_symbols:
+ parts.append(f"新增符号: {', '.join(new_symbols)}")
+ broken = upstream_dict.get("broken_interfaces", [])
+ if broken:
+ parts.append(f"破坏接口: {', '.join(broken)}")
+ diffs = upstream_dict.get("diffs", {})
+ if diffs:
+ parts.append("--- Diffs ---")
+ for file_path, diff_text in diffs.items():
+ parts.append(f"[{file_path}]\n{diff_text}")
return "\n".join(parts)
@staticmethod
@@ -228,3 +283,95 @@ class TaskCompiler:
raise CycleError("Task DAG contains a cycle")
return layers
+
+
+# ── Worktree 隔离:/multi 并行任务文件隔离 ──
+# 理论来源:CC Worktree isolation(arXiv:2604.14228)
+
+class WorktreeManager:
+ """
+ 并行任务文件隔离:每个子任务在独立工作目录执行,避免互相覆盖。
+ - Git 项目:使用 git worktree
+ - 非 Git 项目:使用 tempdir + copytree
+ """
+
+ def __init__(self, project_root: str):
+ import os
+ from pathlib import Path
+ self.root = os.path.abspath(project_root)
+ self._is_git = (Path(self.root) / ".git").exists()
+ self._trees: dict[str, str] = {}
+
+ def create(self, task_id: str) -> str:
+ """为任务创建隔离工作目录,返回路径。"""
+ import subprocess
+ import shutil
+ import tempfile
+ from pathlib import Path
+
+ short_id = task_id[:8]
+
+ if not self._is_git:
+ # 非 Git:复制到临时目录
+ tmp = tempfile.mkdtemp(prefix=f"kwcode_{short_id}_")
+ shutil.copytree(self.root, tmp, dirs_exist_ok=True)
+ self._trees[task_id] = tmp
+ return tmp
+
+ # Git:使用 worktree
+ branch = f"kwcode-{short_id}"
+ path = str(Path(self.root).parent / f".kwcode_wt_{short_id}")
+ try:
+ subprocess.run(
+ ["git", "worktree", "add", "-b", branch, path],
+ cwd=self.root, check=True, capture_output=True,
+ )
+ self._trees[task_id] = path
+ return path
+ except subprocess.CalledProcessError:
+ # worktree 失败时 fallback 到 copytree
+ tmp = tempfile.mkdtemp(prefix=f"kwcode_{short_id}_")
+ shutil.copytree(self.root, tmp, dirs_exist_ok=True)
+ self._trees[task_id] = tmp
+ return tmp
+
+ def cleanup(self, task_id: str, merge: bool = False):
+ """清理工作目录。merge=True 时合并变更回主分支。"""
+ import subprocess
+ import shutil
+
+ path = self._trees.pop(task_id, None)
+ if not path:
+ return
+
+ short_id = task_id[:8]
+
+ if self._is_git:
+ if merge:
+ branch = f"kwcode-{short_id}"
+ subprocess.run(
+ ["git", "merge", "--no-ff", branch],
+ cwd=self.root, capture_output=True,
+ )
+ subprocess.run(
+ ["git", "worktree", "remove", "--force", path],
+ cwd=self.root, capture_output=True,
+ )
+ # 清理分支
+ if not merge:
+ subprocess.run(
+ ["git", "branch", "-D", f"kwcode-{short_id}"],
+ cwd=self.root, capture_output=True,
+ )
+ else:
+ shutil.rmtree(path, ignore_errors=True)
+
+ def cleanup_all(self, merge: bool = False):
+ """清理所有工作目录。"""
+ for task_id in list(self._trees.keys()):
+ self.cleanup(task_id, merge=merge)
+
+ @property
+ def active_count(self) -> int:
+ return len(self._trees)
+
diff --git a/kaiwu/core/wink.py b/kaiwu/core/wink.py
new file mode 100644
index 0000000..d4732ba
--- /dev/null
+++ b/kaiwu/core/wink.py
@@ -0,0 +1,93 @@
+"""
+Wink 自修复监控:轨迹监控 + 偏离检测 + 课程纠正。
+
+轻量异步观察 agent 执行,检测三类问题行为:
+- Specification Drift:偏离用户原始意图(scope creep)
+- Reasoning Problems:同类错误反复(原地打转)
+- Tool Call Failures:patch 持续失败
+
+理论来源:
+- Wink: Recovering from Misbehaviors in Coding Agents(arXiv:2602.17037)
+- CodeScout 问题陈述增强(arXiv:2603.05744)
+"""
+
+import logging
+from typing import Optional
+
+from kaiwu.core.event_bus import EventBus
+
+logger = logging.getLogger(__name__)
+
+
+class WinkMonitor:
+ """
+ 轻量轨迹监控器:不阻塞主流程,纯观察 + 定期检查。
+ 检测到偏离时返回纠正 hint,由 orchestrator 注入 retry prompt。
+ """
+
+ DRIFT_PATTERNS = [
+ # Specification Drift:任务范围过大
+ {
+ "name": "scope_creep",
+ "detect": lambda ctx: (
+ ctx.locator_output and
+ len(ctx.locator_output.get("relevant_files", [])) > 5 and
+ ctx.gate_result.get("difficulty") == "easy"
+ ),
+ "hint": "任务范围过大,只修改用户明确指定的文件,不要扩散到其他文件",
+ },
+ # Reasoning Problems:同类错误反复
+ {
+ "name": "repetitive_fix",
+ "detect": lambda ctx: (
+ hasattr(ctx, '_error_type_streak') and
+ ctx._error_type_streak.get("count", 0) >= 2
+ ),
+ "hint": "你已经用同样的方式修改了 {count} 次,换一个完全不同的思路",
+ },
+ # Tool Call Failures:patch 持续失败
+ {
+ "name": "patch_miss",
+ "detect": lambda ctx: (
+ ctx.verifier_output and
+ ctx.verifier_output.get("error_type") == "patch_apply" and
+ ctx.retry_count >= 1
+ ),
+ "hint": "patch 未命中,文件内容可能已变化,请重新读取文件再生成 patch",
+ },
+ # Generator 输出为空(模型拒绝或无法理解)
+ {
+ "name": "empty_output",
+ "detect": lambda ctx: (
+ ctx.generator_output and
+ not ctx.generator_output.get("patches") and
+ ctx.retry_count >= 1
+ ),
+ "hint": "Generator 未产出有效 patch,尝试简化任务描述或缩小修改范围",
+ },
+ ]
+
+ def check(self, ctx, bus: Optional[EventBus] = None) -> Optional[str]:
+ """
+ 检查当前 context 是否有偏离,返回纠正 hint 或 None。
+ 非阻塞,任何异常静默忽略。
+ """
+ for pattern in self.DRIFT_PATTERNS:
+ try:
+ if pattern["detect"](ctx):
+ # 格式化 hint
+ hint = pattern["hint"]
+ if "{count}" in hint and hasattr(ctx, "_error_type_streak"):
+ hint = hint.format(count=ctx._error_type_streak.get("count", 0))
+
+ if bus:
+ bus.emit("wink_intervene", {
+ "pattern": pattern["name"],
+ "msg": f"检测到 {pattern['name']},注入纠正"
+ })
+
+ logger.info("[wink] detected %s, injecting hint", pattern["name"])
+ return hint
+ except Exception:
+ continue
+ return None
diff --git a/kaiwu/experts/locator.py b/kaiwu/experts/locator.py
index 05faa35..901a01d 100644
--- a/kaiwu/experts/locator.py
+++ b/kaiwu/experts/locator.py
@@ -34,6 +34,10 @@ logger = logging.getLogger(__name__)
LOCATOR_FILE_PROMPT = """你是代码定位专家。根据任务描述,从文件列表中找出最相关的文件。
+重要:首先查看.kaiwu/rig.json(如果存在),它包含项目的文件导出/导入关系、API路由映射和测试覆盖信息。优先利用rig.json中的依赖关系来定位相关文件。
+
+{rig_context}
+
仓库文件结构:
{file_tree}
@@ -187,6 +191,9 @@ class LocatorExpert:
# ── DocReader: inject relevant document paragraphs ──
self._inject_doc_context(ctx)
+ # ── Speculative Prefetch: 后台预读文件到缓存 ──
+ self._prefetch(relevant_files[:5])
+
return result
def _llm_locate(self, ctx: TaskContext, task_desc: str) -> Optional[dict]:
@@ -253,6 +260,19 @@ class LocatorExpert:
return result
+ def _prefetch(self, files: list[str]):
+ """Speculative Prefetch: Locator完成后后台预读文件到内存,减少Generator等待IO。"""
+ import threading
+
+ def _do():
+ for f in files[:5]:
+ try:
+ self.tools.read_file(f)
+ except Exception:
+ pass
+
+ threading.Thread(target=_do, daemon=True, name="prefetch").start()
+
def notify_task_result(self, ctx: TaskContext, success: bool):
"""
Post-task callback:
@@ -301,16 +321,54 @@ class LocatorExpert:
except Exception as e:
logger.debug("[locator] doc_reader skipped: %s", e)
+ def _load_rig_context(self, project_root: str) -> str:
+ """Load rig_summary.json for prompt injection. Returns empty string if unavailable."""
+ rig_path = os.path.join(project_root, ".kaiwu", "rig_summary.json")
+ if not os.path.exists(rig_path):
+ return ""
+ try:
+ import json
+ with open(rig_path, "r", encoding="utf-8") as f:
+ rig = json.load(f)
+ # Build compact summary: routes + key file exports
+ parts = []
+ routes = rig.get("api_routes", {})
+ if routes:
+ parts.append("API路由:")
+ for route, loc in list(routes.items())[:20]:
+ parts.append(f" {route} → {loc}")
+ frontend = rig.get("frontend_api_calls", {})
+ if frontend:
+ parts.append("前端调用:")
+ for route, loc in list(frontend.items())[:20]:
+ parts.append(f" {route} → {loc}")
+ test_cov = rig.get("test_coverage", {})
+ if test_cov:
+ parts.append("测试覆盖:")
+ for src, tests in list(test_cov.items())[:10]:
+ parts.append(f" {src} ← {', '.join(tests)}")
+ return "\n".join(parts) if parts else ""
+ except Exception:
+ return ""
+
def _locate_files(self, file_tree: str, task_desc: str, symbol_index: str = "", ctx: TaskContext = None) -> list[str]:
"""Phase 1: LLM call to find relevant files from tree + symbol index."""
si_section = ""
if symbol_index:
si_section = f"各文件的函数/类定义:\n{symbol_index}"
+ # Load rig.json context for better file location
+ rig_context = ""
+ if ctx:
+ rig_context = self._load_rig_context(ctx.project_root)
+ if rig_context:
+ rig_context = f"项目结构索引(rig.json):\n{rig_context}"
+
prompt = LOCATOR_FILE_PROMPT.format(
file_tree=file_tree[:3000],
symbol_index=si_section[:2000],
task_description=task_desc,
+ rig_context=rig_context[:2000],
)
system = self._build_system(ctx) if ctx else ""
raw = self.llm.generate(prompt=prompt, system=system, max_tokens=300, temperature=0.0)
diff --git a/kaiwu/experts/search_augmentor.py b/kaiwu/experts/search_augmentor.py
index e824f27..51ad27d 100644
--- a/kaiwu/experts/search_augmentor.py
+++ b/kaiwu/experts/search_augmentor.py
@@ -44,9 +44,13 @@ class SearchAugmentorExpert:
self.fetcher = ContentFetcher()
def search(self, ctx: TaskContext) -> str:
- """完整搜索流水线(供重试路径使用)。任何异常返回空字符串。"""
+ """完整搜索流水线(供重试路径使用)。任何异常返回空字符串,不阻塞流水线。"""
t0 = time.time()
try:
+ # 搜索开关检查
+ from kaiwu.search.duckduckgo import _is_search_enabled
+ if not _is_search_enabled():
+ return ""
query = ctx.user_input[:120]
raw = self._search_and_collect(query, t0)
if not raw:
@@ -54,7 +58,7 @@ class SearchAugmentorExpert:
# LLM提取关键信息
return self._extract(query, raw)
except Exception as e:
- logger.error("[search] pipeline error: %s", e)
+ logger.debug("[search] pipeline error (静默): %s", e)
return ""
def search_only(self, query: str) -> str:
diff --git a/kaiwu/search/duckduckgo.py b/kaiwu/search/duckduckgo.py
index 27a0264..e0133da 100644
--- a/kaiwu/search/duckduckgo.py
+++ b/kaiwu/search/duckduckgo.py
@@ -1,10 +1,15 @@
"""
-搜索引擎:SearXNG统一接入(本地Docker),DDG库作为fallback。
-SearXNG覆盖所有搜索场景,不再需要DDG/Bing/wttr.in等特殊处理。
-kwcode启动首次搜索时自动拉起SearXNG容器。
+搜索引擎:DDG库为主,SearXNG为可选增强。
+内网/离线环境静默降级,不报错不阻塞流水线。
+
+网络保护原则:
+- SearXNG 降为可选,不自动拉起 Docker
+- DDG 库也不可用时返回空列表
+- 任何网络异常静默处理,不抛出
"""
import logging
+import os
import subprocess
import time
from typing import Optional
@@ -17,7 +22,7 @@ logger = logging.getLogger(__name__)
DEFAULT_SEARXNG_URL = "http://localhost:8080"
CONTAINER_NAME = "kwcode-searxng"
-# DDG库作为fallback
+# DDG库作为主搜索
try:
from duckduckgo_search import DDGS
HAS_DDGS = True
@@ -25,6 +30,32 @@ except ImportError:
HAS_DDGS = False
+def _is_search_enabled() -> bool:
+ """检查搜索是否启用(config.yaml 中 search_enabled 字段)。"""
+ from pathlib import Path
+ # 环境变量优先
+ env_val = os.environ.get("KWCODE_SEARCH_ENABLED", "").lower()
+ if env_val in ("0", "false", "no", "off"):
+ return False
+ if env_val in ("1", "true", "yes", "on"):
+ return True
+ # 读 config
+ for dirname in (".kwcode", ".kaiwu"):
+ config_path = os.path.join(Path.home(), dirname, "config.yaml")
+ if os.path.exists(config_path):
+ try:
+ import yaml
+ with open(config_path, "r", encoding="utf-8") as f:
+ cfg = yaml.safe_load(f) or {}
+ val = cfg.get("search_enabled")
+ if val is not None:
+ return bool(val)
+ except Exception:
+ pass
+ # 默认启用
+ return True
+
+
def _get_searxng_url() -> str:
"""从config或环境变量读取SearXNG地址。"""
import os
@@ -199,36 +230,43 @@ _searxng_ok: Optional[bool] = None
def search(query: str, max_results: int = 10, timeout: float = 10.0) -> list[dict]:
"""
- 搜索入口:SearXNG + DDG 并行执行,结果去重合并。
+ 搜索入口:DDG为主,SearXNG为可选增强。
+ 内网/离线环境静默返回空列表,不报错不阻塞。
返回 [{url, title, snippet}, ...]
"""
global _searxng_ok
+ # ── 搜索开关:离线用户可完全禁用 ──
+ if not _is_search_enabled():
+ logger.debug("[search] 搜索已禁用(search_enabled=false)")
+ return []
+
searxng_url = _get_searxng_url()
- # 首次检测SearXNG可用性(缓存整个session)
+ # 首次检测SearXNG可用性(缓存整个session,不自动拉起Docker)
if _searxng_ok is None:
_searxng_ok = _searxng_available(searxng_url)
- if not _searxng_ok:
- logger.info("[search] SearXNG不可用,尝试自动启动...")
- if _try_start_searxng():
- _searxng_ok = True
- else:
- logger.info("[search] SearXNG自动启动失败,使用DDG fallback")
if _searxng_ok:
logger.info("[search] SearXNG可用: %s", searxng_url)
+ else:
+ logger.debug("[search] SearXNG不可用,使用DDG")
# 并行搜索:SearXNG + DDG 同时跑,结果去重合并
- if _searxng_ok and HAS_DDGS:
- return _search_parallel(query, max_results, timeout, searxng_url)
+ try:
+ if _searxng_ok and HAS_DDGS:
+ return _search_parallel(query, max_results, timeout, searxng_url)
- # 单引擎 fallback
- if _searxng_ok:
- results = _search_searxng(query, max_results, timeout, searxng_url)
- if results:
- return results
+ # 单引擎 fallback
+ if _searxng_ok:
+ results = _search_searxng(query, max_results, timeout, searxng_url)
+ if results:
+ return results
- return _search_ddg(query, max_results, timeout)
+ return _search_ddg(query, max_results, timeout)
+ except Exception as e:
+ # 任何网络异常静默处理,返回空列表
+ logger.debug("[search] 搜索异常(静默): %s", e)
+ return []
def _search_parallel(query: str, max_results: int, timeout: float, searxng_url: str) -> list[dict]:
diff --git a/kaiwu/search/search_router.py b/kaiwu/search/search_router.py
new file mode 100644
index 0000000..38702c7
--- /dev/null
+++ b/kaiwu/search/search_router.py
@@ -0,0 +1,285 @@
+"""
+SearchRouter: 意图感知搜索路由,零 key 默认可用。
+
+分层架构:
+ Layer 0:专项 API(零 key,最精准)
+ - arxiv.org API → 研究论文
+ - Semantic Scholar → 学术搜索
+ - GitHub REST API → 开源代码(60次/小时)
+ - PyPI JSON API → 包文档
+ - Open-Meteo API → 天气数据
+ Layer 1:DuckDuckGo(零 key,通用搜索)
+ Layer 2:Tavily(可选 key,质量提升)
+
+理论来源:
+- ARCS retrieval-before-generation(arXiv:2504.20434)
+- Wink 失败类型分类(arXiv:2602.17037)
+"""
+
+import logging
+import re
+from typing import Optional
+
+import httpx
+
+from kaiwu.core.network import get_httpx_kwargs
+
+logger = logging.getLogger(__name__)
+
+_TIMEOUT = 10.0
+
+
+def arxiv_search(query: str, max_results: int = 5) -> list[dict]:
+ """arXiv API 搜索(零 key,无限制)。"""
+ try:
+ import urllib.parse
+ q = urllib.parse.quote(query)
+ url = f"http://export.arxiv.org/api/query?search_query=all:{q}&max_results={max_results}&sortBy=relevance"
+ resp = httpx.get(url, timeout=_TIMEOUT, **get_httpx_kwargs())
+ if resp.status_code != 200:
+ return []
+ # 简单 XML 解析
+ results = []
+ entries = re.findall(r'