From 5a6803fc30986ce807db440b8c8c562eac22461a Mon Sep 17 00:00:00 2001 From: Val-sss <154882199@qq.com> Date: Thu, 14 May 2026 07:11:38 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v2.1.0=20=E2=80=94=20ReAct=E5=A4=9A?= =?UTF-8?q?=E8=BD=AE=E4=BA=A4=E4=BA=92=E5=BE=AA=E7=8E=AF=EF=BC=8CEval=2022?= =?UTF-8?q?%=E2=86=9253%=20(26/50=20PASS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增ReAct循环(kaiwu/agent/react_loop.py): - 工具集: read_file/write_file/run_test/grep/list_dir/submit - 门控: retry_count≥1时触发,小模型5步/中8步/大10步 - 上下文管理: 保留system+初始user+最近6轮,早期压缩 - 安全: 写入前Python语法检查,禁写测试文件,失败恢复 Eval结果 (qwen2.5-coder:32b-instruct-q4_K_M, 50题): - 总通过率: 26/50 (53%), 对比v2.0.0的8/36(22%)提升141% - Go题: 6/9 PASS (t42/t43/t44/t46/t56/t57) - 多文件API: 3/3 全过 (t51/t52/t53) - 综合题: t10_comprehensive PASS (425s, 之前timeout) Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 5 +- kaiwu/agent/__init__.py | 1 + kaiwu/agent/react_loop.py | 432 +++++++++++++++++++++++++++++++++++++ kaiwu/experts/generator.py | 82 ++++++- pyproject.toml | 2 +- 5 files changed, 516 insertions(+), 6 deletions(-) create mode 100644 kaiwu/agent/__init__.py create mode 100644 kaiwu/agent/react_loop.py diff --git a/README.md b/README.md index e28d43d..bb16cdd 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,13 @@ [![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://python.org) [![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Mac%20%7C%20Linux-lightgrey.svg)]() [![Multi-Platform Tests](https://github.com/val1813/kwcode/actions/workflows/test.yml/badge.svg)](https://github.com/val1813/kwcode/actions/workflows/test.yml) -[![Version](https://img.shields.io/badge/Version-2.0.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.1.0-blue.svg)]() --- -> **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"