diff --git a/README.md b/README.md
index e28d43d..bb16cdd 100644
--- a/README.md
+++ b/README.md
@@ -10,13 +10,13 @@
[](https://python.org)
[]()
[](https://github.com/val1813/kwcode/actions/workflows/test.yml)
-[]()
+[]()
---
-> **v2.0.0 已发布!** Eval驱动架构升级:自适应采样 + 调用关系传递(usage_finder) + 多bug逐类拆解(bug_decomposed) + 新文件创建 + 工程约束注入 + bench诊断报告系统。Eval通过率 0%→20%(3/15 PASS),总测试通过数 294→377(+28%)。安装命令:
+> **v2.1.0 已发布!** ReAct多轮交互循环 + 任务失败自动重跑。Eval通过率 22%→**53%**(26/50 PASS,含Go/多文件API题),对比v2.0.0提升141%。安装命令:
>
> ```bash
> pip install kwcode
@@ -30,6 +30,7 @@
| 日期 | 内容 |
|------|------|
+| 05-14 | **v2.1.0** ReAct多轮交互循环(react_loop.py, 工具集read/write/run_test/grep/list_dir/submit) + 任务失败自动重跑(retry_count≥1触发ReAct) + 上下文管理(保留system+初始user+最近6轮) + 安全写入(Python语法检查+禁写测试文件+失败恢复) + **Eval 26/50 PASS (53%)**, Go题6/9通过, 多文件API题3/3全过 |
| 05-09 | **v2.0.0** Eval驱动架构升级:自适应采样(大文件1次/小文件3次) + usage_finder调用关系传递 + bug_decomposed多bug逐类拆解 + 新文件创建(_maybe_create_missing_module) + 工程约束注入(_detect_engineering_hints) + bench诊断报告系统(bench_diagnose.py) + 3个失败测试清理 + Eval 0%→20%(3/15 PASS, +83 tests) |
| 05-08 | **v1.9.0** 工程消化反馈:execution_feedback内循环(改完立即跑测试看结果) + 小模型自适应(8b以下只采1次) + 结构化诊断句生成(generate_diagnosis) + 精确失败归因(attribute_failures_to_files) |
| 05-07 | **v1.8.0** 21项修复:whole_file_refactor + targeted_fix + fault_functions定位 + regression_guard + checkpoint不覆盖 + syntax免费重试 + error[-3000:]取末尾 + CJK截断 |
diff --git a/kaiwu/agent/__init__.py b/kaiwu/agent/__init__.py
new file mode 100644
index 0000000..eba4019
--- /dev/null
+++ b/kaiwu/agent/__init__.py
@@ -0,0 +1 @@
+# kaiwu.agent — ReAct loop for multi-turn interactive coding
diff --git a/kaiwu/agent/react_loop.py b/kaiwu/agent/react_loop.py
new file mode 100644
index 0000000..0d49c59
--- /dev/null
+++ b/kaiwu/agent/react_loop.py
@@ -0,0 +1,432 @@
+"""
+ReAct Loop: 多轮交互式代码修复Agent。
+
+LLM在环境中自主决定:读文件、写文件、跑测试、搜索代码,
+每步看到真实反馈,直到提交最终方案或达到步数上限。
+
+替代 _run_targeted_fix 中的单次LLM调用模式,
+让模型能够迭代式探索和修复,突破单次黑盒调用的天花板。
+"""
+
+import ast
+import logging
+import re
+import time
+from dataclasses import dataclass, field
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+
+# ── Tool Definitions (给LLM看的工具描述) ──
+
+TOOL_DESCRIPTIONS = """\
+你可以使用以下工具来探索和修改代码。每次回复只能调用一个工具。
+
+## 可用工具
+
+### read_file
+读取文件内容。
+格式: read_file文件路径
+
+### write_file
+写入完整文件内容(覆盖)。
+格式: write_file文件路径
+
+完整文件内容
+
+
+### run_test
+运行项目测试,查看哪些通过哪些失败。
+格式: run_test
+
+### grep
+在项目中搜索代码模式。
+格式: grep搜索模式
+
+### list_dir
+列出目录内容。
+格式: list_dir目录路径(可选,默认项目根目录)
+
+### submit
+提交最终方案,结束修复。确认所有修改已完成后调用。
+格式: submit
+
+## 重要规则
+- 每次回复只调用一个工具
+- 先读文件了解现状,再修改
+- 修改后跑测试验证
+- 不要修改测试文件
+- 目标:让所有测试通过
+"""
+
+
+@dataclass
+class ReactStep:
+ """一步ReAct交互的记录。"""
+ thought: str = ""
+ tool: str = ""
+ tool_arg: str = ""
+ tool_content: str = "" # write_file的content
+ observation: str = ""
+ elapsed_ms: float = 0
+
+
+@dataclass
+class ReactResult:
+ """ReAct循环的最终结果。"""
+ success: bool = False
+ steps: list = field(default_factory=list)
+ final_files: dict = field(default_factory=dict) # {path: content}
+ tests_passed: int = 0
+ tests_total: int = 0
+ total_elapsed_s: float = 0
+
+
+class ReactLoop:
+ """
+ 多轮ReAct循环Agent。
+
+ 使用方式:
+ loop = ReactLoop(llm, tools, max_steps=10)
+ result = loop.run(ctx, target_files)
+ """
+
+ def __init__(self, llm, tools, max_steps: int = 10, step_timeout: int = 120):
+ """
+ Args:
+ llm: LLMBackend instance (支持 chat/generate)
+ tools: ToolExecutor instance
+ max_steps: 最大交互步数
+ step_timeout: 每步工具执行超时(秒)
+ """
+ self.llm = llm
+ self.tools = tools
+ self.max_steps = max_steps
+ self.step_timeout = step_timeout
+
+ def run(self, ctx, target_files: list[str]) -> ReactResult:
+ """
+ 执行ReAct循环。
+
+ Args:
+ ctx: TaskContext (包含 user_input, project_root, verifier_output 等)
+ target_files: 需要修复的目标文件列表
+
+ Returns:
+ ReactResult with final state
+ """
+ t0 = time.time()
+ result = ReactResult()
+
+ # 构建初始system prompt
+ system = self._build_system_prompt(ctx, target_files)
+
+ # 构建初始user message(任务描述+当前状态)
+ initial_msg = self._build_initial_message(ctx, target_files)
+
+ # 对话历史
+ messages = [
+ {"role": "system", "content": system},
+ {"role": "user", "content": initial_msg},
+ ]
+
+ # 记录文件修改(用于最终输出)
+ modified_files = {}
+
+ for step_idx in range(self.max_steps):
+ step = ReactStep()
+ step_t0 = time.time()
+
+ # 调用LLM
+ # 根据模型大小调整token预算
+ is_small = self._is_small_model()
+ max_tokens = 2048 if is_small else 4096
+
+ response = self.llm.chat(
+ messages=messages,
+ max_tokens=max_tokens,
+ temperature=0.0,
+ )
+
+ if not response:
+ logger.warning("[react] LLM returned empty response at step %d", step_idx)
+ break
+
+ # 解析LLM输出:thought + tool call
+ step.thought, step.tool, step.tool_arg, step.tool_content = self._parse_response(response)
+
+ logger.info("[react] step %d: tool=%s arg=%s", step_idx, step.tool, step.tool_arg[:80] if step.tool_arg else "")
+
+ # 执行工具
+ if step.tool == "submit":
+ step.observation = "已提交。"
+ step.elapsed_ms = (time.time() - step_t0) * 1000
+ result.steps.append(step)
+ result.success = True
+ break
+
+ elif step.tool == "read_file":
+ step.observation = self._exec_read_file(ctx, step.tool_arg)
+
+ elif step.tool == "write_file":
+ path = step.tool_arg
+ content = step.tool_content
+ ok = self._exec_write_file(ctx, path, content)
+ if ok:
+ modified_files[path] = content
+ step.observation = f"已写入 {path} ({len(content)} bytes)"
+ else:
+ step.observation = f"写入失败: {path}"
+
+ elif step.tool == "run_test":
+ test_result = self._exec_run_test(ctx)
+ step.observation = test_result
+ # 解析测试结果
+ passed, total = self._parse_test_counts(test_result)
+ result.tests_passed = passed
+ result.tests_total = total
+
+ elif step.tool == "grep":
+ step.observation = self._exec_grep(ctx, step.tool_arg)
+
+ elif step.tool == "list_dir":
+ step.observation = self._exec_list_dir(ctx, step.tool_arg)
+
+ else:
+ # 无法识别的工具或LLM没有调用工具
+ step.observation = f"未识别的工具: {step.tool}。请使用可用工具之一。"
+ if not step.tool:
+ # LLM可能只输出了思考没有调用工具,提醒它
+ step.observation = "请调用一个工具来继续。可用工具: read_file, write_file, run_test, grep, list_dir, submit"
+
+ step.elapsed_ms = (time.time() - step_t0) * 1000
+ result.steps.append(step)
+
+ # 将assistant回复和observation加入对话历史
+ messages.append({"role": "assistant", "content": response})
+ messages.append({"role": "user", "content": f"[观察结果]\n{step.observation}"})
+
+ # 上下文窗口管理:如果历史太长,压缩早期步骤
+ messages = self._maybe_compress_history(messages)
+
+ result.final_files = modified_files
+ result.total_elapsed_s = time.time() - t0
+
+ # 如果没有显式submit但有修改,也算部分成功
+ if not result.success and modified_files:
+ # 跑一次最终测试确认状态
+ final_test = self._exec_run_test(ctx)
+ passed, total = self._parse_test_counts(final_test)
+ result.tests_passed = passed
+ result.tests_total = total
+
+ logger.info("[react] completed: %d steps, %d files modified, %d/%d tests, %.1fs",
+ len(result.steps), len(modified_files),
+ result.tests_passed, result.tests_total, result.total_elapsed_s)
+
+ return result
+
+ # ── System & Initial Message Construction ──
+
+ def _build_system_prompt(self, ctx, target_files: list[str]) -> str:
+ """构建system prompt,包含工具描述和约束。"""
+ parts = [
+ "你是一个代码修复Agent。你的任务是通过多轮交互修复代码中的bug,使所有测试通过。",
+ "",
+ TOOL_DESCRIPTIONS,
+ "",
+ "## 工作流程建议",
+ "1. 先 read_file 查看目标文件和测试文件",
+ "2. 分析失败原因",
+ "3. write_file 修复代码",
+ "4. run_test 验证修复",
+ "5. 如果还有失败,继续分析和修复",
+ "6. 全部通过后 submit",
+ "",
+ f"## 项目根目录: {ctx.project_root}",
+ f"## 目标文件: {', '.join(target_files)}",
+ ]
+ return "\n".join(parts)
+
+ def _build_initial_message(self, ctx, target_files: list[str]) -> str:
+ """构建初始消息,包含任务描述和当前失败信息。"""
+ parts = [f"## 任务\n{ctx.user_input}"]
+
+ # 当前测试失败信息
+ if ctx.verifier_output:
+ error_detail = ctx.verifier_output.get("error_detail", "")
+ if error_detail:
+ # 截断过长的错误信息
+ if len(error_detail) > 2000:
+ error_detail = error_detail[:2000] + "\n... (截断)"
+ parts.append(f"\n## 当前测试失败\n{error_detail}")
+
+ passed = ctx.verifier_output.get("tests_passed", 0)
+ total = ctx.verifier_output.get("tests_total", 0)
+ if total > 0:
+ parts.append(f"\n当前状态: {passed}/{total} 测试通过")
+
+ # 已有的最佳进展
+ if ctx.best_tests_passed > 0:
+ parts.append(f"\n已有最佳进展: {ctx.best_tests_passed} 个测试通过,请在此基础上继续修复。")
+
+ # retry hint
+ if ctx.retry_hint:
+ parts.append(f"\n## 提示\n{ctx.retry_hint}")
+
+ parts.append("\n请开始修复。先读取相关文件了解现状。")
+ return "\n".join(parts)
+
+ # ── Response Parsing ──
+
+ def _parse_response(self, response: str) -> tuple[str, str, str, str]:
+ """
+ 解析LLM回复,提取thought和tool call。
+
+ Returns: (thought, tool_name, tool_arg, tool_content)
+ """
+ # 提取tool call
+ tool_match = re.search(r'(.*?)', response)
+ arg_match = re.search(r'(.*?)', response, re.DOTALL)
+ content_match = re.search(r'\n?(.*?)', response, re.DOTALL)
+
+ tool = tool_match.group(1).strip() if tool_match else ""
+ tool_arg = arg_match.group(1).strip() if arg_match else ""
+ tool_content = content_match.group(1) if content_match else ""
+
+ # thought是tool标签之前的所有文本
+ if tool_match:
+ thought = response[:tool_match.start()].strip()
+ else:
+ thought = response.strip()
+
+ return thought, tool, tool_arg, tool_content
+
+ # ── Tool Execution ──
+
+ def _exec_read_file(self, ctx, path: str) -> str:
+ """读取文件,返回内容或错误。"""
+ if not path:
+ return "[ERROR] 请提供文件路径"
+ content = self.tools.read_file(path)
+ if content and not content.startswith("[ERROR]"):
+ # 截断过大的文件
+ lines = content.split('\n')
+ if len(lines) > 300:
+ return '\n'.join(lines[:300]) + f"\n\n... (文件共{len(lines)}行,已截断前300行)"
+ return content
+
+ def _exec_write_file(self, ctx, path: str, content: str) -> bool:
+ """写入文件。禁止写测试文件。"""
+ if not path or not content:
+ return False
+ # 禁止修改测试文件
+ if "test" in path.lower():
+ logger.warning("[react] blocked write to test file: %s", path)
+ return False
+ # 语法检查(仅Python)
+ if path.endswith('.py'):
+ try:
+ ast.parse(content)
+ except SyntaxError as e:
+ logger.warning("[react] syntax error in write: %s", e)
+ return False
+ return self.tools.write_file(path, content)
+
+ def _exec_run_test(self, ctx) -> str:
+ """运行测试,返回输出。"""
+ from kaiwu.core.context import TaskContext as _TC
+ from kaiwu.experts.verifier import VerifierExpert as _VE
+
+ _tmp_ctx = _TC(project_root=ctx.project_root)
+ _tmp_ver = _VE(self.llm, self.tools)
+ result = _tmp_ver.run_tests_only(_tmp_ctx)
+
+ output = result.get("output", "")
+ passed = result.get("passed", 0)
+ total = result.get("total", 0)
+
+ # 构建简洁的测试摘要
+ summary = f"测试结果: {passed}/{total} 通过"
+ if passed == total and total > 0:
+ summary += " (全部通过!)"
+
+ # 附加失败详情(截断)
+ if output and passed < total:
+ if len(output) > 1500:
+ output = output[:1500] + "\n... (截断)"
+ return f"{summary}\n\n{output}"
+ return summary
+
+ def _exec_grep(self, ctx, pattern: str) -> str:
+ """在项目中搜索代码。"""
+ if not pattern:
+ return "[ERROR] 请提供搜索模式"
+ import subprocess
+ try:
+ # 使用grep搜索(跨平台兼容)
+ cmd = f'grep -rn "{pattern}" --include="*.py" --include="*.ts" --include="*.js" --include="*.go" .'
+ stdout, stderr, rc = self.tools.run_bash(cmd, cwd=ctx.project_root, timeout=10)
+ if stdout:
+ lines = stdout.strip().split('\n')
+ if len(lines) > 30:
+ return '\n'.join(lines[:30]) + f"\n... (共{len(lines)}个匹配,显示前30个)"
+ return stdout.strip()
+ return f"未找到匹配: {pattern}"
+ except Exception as e:
+ return f"[ERROR] grep failed: {e}"
+
+ def _exec_list_dir(self, ctx, path: str) -> str:
+ """列出目录内容。"""
+ target = path if path else "."
+ entries = self.tools.list_dir(target)
+ if isinstance(entries, list):
+ if entries and entries[0].startswith("[ERROR]"):
+ return entries[0]
+ return '\n'.join(entries[:50])
+ return str(entries)
+
+ # ── Helpers ──
+
+ def _is_small_model(self) -> bool:
+ """检测是否为小模型。"""
+ model_name = getattr(self.llm, 'ollama_model', '').lower()
+ return any(s in model_name for s in ('1b', '3b', '4b', '7b', '8b'))
+
+ def _parse_test_counts(self, test_output: str) -> tuple[int, int]:
+ """从测试输出中解析通过/总数。"""
+ # 匹配 "X/Y 通过" 格式
+ m = re.search(r'(\d+)/(\d+)\s*通过', test_output)
+ if m:
+ return int(m.group(1)), int(m.group(2))
+ # 匹配 pytest 格式 "X passed, Y failed"
+ passed_m = re.search(r'(\d+)\s*passed', test_output)
+ failed_m = re.search(r'(\d+)\s*failed', test_output)
+ passed = int(passed_m.group(1)) if passed_m else 0
+ failed = int(failed_m.group(1)) if failed_m else 0
+ if passed or failed:
+ return passed, passed + failed
+ return 0, 0
+
+ def _maybe_compress_history(self, messages: list[dict]) -> list[dict]:
+ """
+ 如果对话历史过长,压缩早期步骤。
+ 保留system + 初始user + 最近6轮交互。
+ """
+ # system(1) + initial_user(1) + pairs(assistant+user) = 2 + 2*N
+ # 保留最近6轮 = 12条消息 + 2条头部 = 14条
+ max_messages = 14
+ if len(messages) <= max_messages:
+ return messages
+
+ # 保留 system + initial_user + 最近的交互
+ head = messages[:2] # system + initial user
+ tail = messages[-(max_messages - 2):] # 最近的交互
+
+ # 插入压缩摘要
+ compressed_count = len(messages) - max_messages
+ summary = f"[前{compressed_count // 2}步已压缩。你已经读取了文件并进行了一些修改。请继续基于最近的观察结果工作。]"
+ head.append({"role": "user", "content": summary})
+
+ return head + tail
diff --git a/kaiwu/experts/generator.py b/kaiwu/experts/generator.py
index 47d66ae..9fcb84f 100644
--- a/kaiwu/experts/generator.py
+++ b/kaiwu/experts/generator.py
@@ -228,9 +228,15 @@ class GeneratorExpert:
# 需要整文件scope的任务:重构(提取函数/拆分类)或综合任务(bug+refactor)
# 判断条件:任务需要新增类/函数,或测试要求代码行数缩短/拆分
if self._needs_whole_file_scope(ctx, files, funcs):
- # Retry时用targeted_fix(基于当前磁盘状态,带函数级定位)
- if ctx.best_tests_passed > 0:
- return self._run_targeted_fix(ctx, files)
+ # Retry时:优先尝试ReAct循环(多轮交互),fallback到targeted_fix
+ if ctx.retry_count >= 1:
+ react_result = self._try_react_loop(ctx, files)
+ if react_result:
+ return react_result
+ # ReAct失败,如果有best_state用targeted_fix,否则再试whole_file
+ if ctx.best_tests_passed > 0:
+ return self._run_targeted_fix(ctx, files)
+ return self._run_whole_file_refactor(ctx, files)
# 首次attempt始终用whole_file_refactor(更稳定,t04靠这个PASS)
return self._run_whole_file_refactor(ctx, files)
@@ -2584,3 +2590,73 @@ class GeneratorExpert:
i += 1
return stubs
+
+ def _try_react_loop(self, ctx: TaskContext, files: list[str]) -> Optional[dict]:
+ """尝试用ReAct循环修复。仅LARGE模型+多文件/复杂任务时启用。
+ 返回patches dict或None(表示fallback到targeted_fix)。"""
+ # 条件:只有LARGE模型才启用ReAct(小模型token消耗太大)
+ from kaiwu.core.model_capability import ModelTier, detect_model_tier
+ model_name = getattr(self.llm, 'ollama_model', '') or ''
+ ollama_url = getattr(self.llm, 'ollama_url', 'http://localhost:11434')
+ try:
+ tier = detect_model_tier(model_name, ollama_url)
+ except Exception:
+ tier = ModelTier.MEDIUM
+
+ # 所有tier都允许ReAct(小模型步数少一些)
+ # 注意:retry_count检查已移到调用方(generator.run()中 ctx.retry_count>=1 才进入此分支)
+
+ # 过滤测试文件
+ target_files = [f for f in files[:3] if "test" not in f.lower()]
+ if not target_files:
+ return None
+
+ try:
+ from kaiwu.agent.react_loop import ReactLoop
+ loop = ReactLoop(
+ llm=self.llm,
+ tools=self.tools,
+ max_steps=5 if tier == ModelTier.SMALL else (8 if tier == ModelTier.MEDIUM else 10),
+ step_timeout=120,
+ )
+ result = loop.run(ctx, target_files)
+
+ # 如果ReAct有修改文件且测试有改善,转换为patches
+ if result.final_files and result.tests_passed > ctx.best_tests_passed:
+ patches = []
+ for fpath, content in result.final_files.items():
+ patches.append({
+ "file": fpath,
+ "content": content,
+ "write_mode": "whole_file",
+ })
+ output = {
+ "patches": patches,
+ "explanation": f"ReAct loop: {len(result.steps)} steps, {result.tests_passed}/{result.tests_total} tests passed",
+ }
+ ctx.generator_output = output
+ logger.info("[react] success: %d patches, %d/%d tests",
+ len(patches), result.tests_passed, result.tests_total)
+ return output
+ else:
+ logger.info("[react] no improvement (passed=%d, best=%d), falling back",
+ result.tests_passed, ctx.best_tests_passed)
+ # 恢复best_code_snapshot(ReAct可能改了磁盘文件)
+ if ctx.best_code_snapshot:
+ for fname, content in ctx.best_code_snapshot.items():
+ try:
+ self.tools.write_file(fname, content)
+ except Exception:
+ pass
+ return None
+
+ except Exception as e:
+ logger.warning("[react] failed with error: %s, falling back to targeted_fix", e)
+ # 恢复best_code_snapshot
+ if ctx.best_code_snapshot:
+ for fname, content in ctx.best_code_snapshot.items():
+ try:
+ self.tools.write_file(fname, content)
+ except Exception:
+ pass
+ return None
diff --git a/pyproject.toml b/pyproject.toml
index 1642942..96ae32f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "kwcode"
-version = "2.0.0"
+version = "2.1.0"
description = "KwCode - Local-model coding agent with MoE expert pipeline"
requires-python = ">=3.10"
readme = "README.md"