diff --git a/CHANGELOG.md b/CHANGELOG.md index 1967091..cc30887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ All notable changes to KWCode are documented here. --- +## [1.7.0] - 2026-05-07 + +### KAIJU架构借鉴 + DetailedLogger + Bounded Context + +**核心理念**:借鉴KAIJU三个具体机制——bounded context per node、sub-task decomposition、完整流水线日志。 + +### Added + +- **DetailedLogger完整流水线日志**(`kaiwu/audit/detailed_logger.py`):每个任务生成独立JSON日志到`logs/`目录,记录LLM完整prompt/output(不截断)、各节点输入输出、工程决策(重试/熔断/搜索)。环境变量`KWCODE_DETAIL_LOG_DIR`可配置输出目录 +- **LLM Backend on_call钩子**:每次LLM调用自动触发回调,记录完整messages和response到DetailedLogger +- **存根任务sub-task decomposition**(`_run_stub_decomposed`):多个pass函数不再一次性让LLM实现,而是逐函数独立调用,每个函数独立context+独立失败筛选,一个函数失败不影响其他 +- **`_find_stub_functions`**:精确检测文件中的stub函数(pass/.../ return None/raise NotImplementedError) +- **`_filter_relevant_failures`**:按函数名/文件名筛选相关测试失败,只给LLM看它需要的信息 + +### Changed + +- **Generator bounded context**:`_generate_modified`不再注入全部8条structured_failures和完整retry_hint,而是只传与当前函数相关的失败(通过函数名/文件名匹配筛选),retry_hint截断到300字符 +- **OpenAI兼容API检测修复**:localhost非标准端口(如kaiwu部署器11435)现在通过探测`/api/tags`判断是否Ollama,不再错误地走`/api/chat`导致404 +- **Gate LLM调用记录**:`_llm_minimal_classify`中增加debug日志记录prompt和raw output + +### Fixed + +- **kaiwu部署器(llama.cpp server)兼容**:`_detect_openai_compat`对`127.0.0.1:11435`正确返回True(OpenAI兼容),不再当作Ollama + +--- + ## [1.6.2] - 2026-05-07 ### 执行反馈深度升级 + 存根任务修复 diff --git a/README.md b/README.md index e944b69..3151770 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-1.6.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-1.7.0-blue.svg)]() --- -> **v1.6.2 已发布!** 执行反馈深度升级:结构化测试失败解析 + TraceCoder历史教训累积 + whole_file写入修复。安装命令: +> **v1.7.0 已发布!** KAIJU架构借鉴:Generator bounded context + 存根任务sub-task decomposition + DetailedLogger完整流水线日志。安装命令: > > ```bash > pip install kwcode @@ -30,6 +30,7 @@ | 日期 | 内容 | |------|------| +| 05-07 | **v1.7.0** KAIJU架构借鉴:Generator bounded context(只传当前函数+相关测试) + 存根任务sub-task decomposition(逐函数独立实现) + DetailedLogger完整流水线日志(不截断LLM输入输出) + OpenAI兼容API检测修复(kaiwu部署器兼容) | | 05-07 | **v1.6.2** 执行反馈深度升级:结构化测试失败解析(parse_test_failures) + TraceCoder历史教训累积(attempt_history) + whole_file写入修复(存根任务不再patches=0) + 完整审计日志(llm_calls/node_io) + pytest -v详细输出 | | 05-07 | **v1.6.1** 架构收敛:删除WholeFileImplExpert/DependencyFixExpert,纯确定性机制驱动pipeline。Generator增强(upstream_constraints注入system prompt + retry_hint携带上次代码 + tier=small填空框架)。License改为Apache-2.0。513 tests green | | 05-07 | **v1.6.0** MoE确定性架构:GapDetector(11种GapType,零LLM) + ExecutionStateTracker(回归检测) + EnvProber(工具链/依赖自动修复) + Gate确定性优先路由 + 63个专项诊断测试 | diff --git a/README_zh.md b/README_zh.md index 97c0161..5e4bca9 100644 --- a/README_zh.md +++ b/README_zh.md @@ -10,8 +10,7 @@ [![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)]() [![Tests](https://img.shields.io/badge/Tests-282%2F282-brightgreen.svg)]() -[![Version](https://img.shields.io/badge/Version-0.7.0-blue.svg)]() - +[![Version](https://img.shields.io/badge/Version-1.7.0-blue.svg)]() --- diff --git a/kaiwu/audit/detailed_logger.py b/kaiwu/audit/detailed_logger.py new file mode 100644 index 0000000..aaf974f --- /dev/null +++ b/kaiwu/audit/detailed_logger.py @@ -0,0 +1,184 @@ +""" +DetailedLogger: 完整不截断的流水线日志记录器。 + +每个任务生成一个 JSON 文件,记录: +- LLM 完整 prompt/output(不截断) +- 各节点(Gate/Locator/Generator/Verifier)的输入输出 +- 工程机制决策(重试策略、搜索、熔断等) + +输出目录:环境变量 KWCODE_DETAIL_LOG_DIR,默认为项目 logs/ 目录。 +设为空字符串可禁用。 +""" + +import json +import logging +import os +import time +from datetime import datetime +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +# 默认日志目录(相对于源码根目录,开发调试用) +_DEFAULT_LOG_DIR = Path(__file__).resolve().parent.parent.parent / "logs" + + +def _get_log_dir() -> Optional[Path]: + """获取详细日志输出目录。返回 None 表示禁用。""" + env_val = os.environ.get("KWCODE_DETAIL_LOG_DIR") + if env_val is not None: + if env_val == "": + return None # 显式禁用 + return Path(env_val) + return _DEFAULT_LOG_DIR + + +class DetailedLogger: + """单任务详细日志记录器。非阻塞,所有操作失败静默。""" + + def __init__(self, user_input: str = "", model: str = "unknown"): + self._enabled = True + self._log_dir = _get_log_dir() + if self._log_dir is None: + self._enabled = False + return + + self._start_time = time.time() + self._task_id = datetime.now().strftime("%Y-%m-%d_%H%M%S") + self._user_input = user_input + self._model = model + self._timeline: list[dict] = [] + self._metadata: dict = {} + + @property + def enabled(self) -> bool: + return self._enabled + + def set_metadata(self, **kwargs): + """设置任务级元数据(expert_type, difficulty, routing_source 等)。""" + if not self._enabled: + return + self._metadata.update(kwargs) + + def log_llm(self, caller: str, prompt: str, system: str, + raw_output: str, tokens: Optional[dict] = None, + elapsed_ms: float = 0, messages: Optional[list] = None): + """ + 记录一次 LLM 调用(完整不截断)。 + + Args: + caller: 调用方标识(gate/generator/verifier/reflection 等) + prompt: 完整 prompt 文本(非 chat 模式时) + system: system prompt + raw_output: LLM 原始输出 + tokens: {"input": N, "output": N} + elapsed_ms: 调用耗时毫秒 + messages: chat 模式的完整 messages 列表 + """ + if not self._enabled: + return + try: + entry = { + "time": datetime.now().strftime("%H:%M:%S.%f")[:-3], + "elapsed_s": round(time.time() - self._start_time, 2), + "type": "llm_call", + "caller": caller, + "input": {}, + "output": raw_output, + "elapsed_ms": round(elapsed_ms, 1), + "tokens": tokens or {}, + } + if messages: + entry["input"]["messages"] = messages + else: + entry["input"]["system"] = system + entry["input"]["prompt"] = prompt + self._timeline.append(entry) + except Exception: + pass + + def log_node(self, stage: str, input_data: dict, output_data: dict, + detail: str = ""): + """ + 记录一个流水线节点的输入输出。 + + Args: + stage: 节点名称(gate/locator/generator/verifier/search 等) + input_data: 节点接收的输入 + output_data: 节点产出的输出 + detail: 可选的补充说明 + """ + if not self._enabled: + return + try: + entry = { + "time": datetime.now().strftime("%H:%M:%S.%f")[:-3], + "elapsed_s": round(time.time() - self._start_time, 2), + "type": "node_io", + "stage": stage, + "input": input_data, + "output": output_data, + } + if detail: + entry["detail"] = detail + self._timeline.append(entry) + except Exception: + pass + + def log_decision(self, stage: str, decision: str, reason: str = "", + context: Optional[dict] = None): + """ + 记录一个工程决策(重试策略选择、熔断、搜索触发等)。 + + Args: + stage: 决策发生的阶段 + decision: 决策内容 + reason: 决策原因 + context: 相关上下文数据 + """ + if not self._enabled: + return + try: + entry = { + "time": datetime.now().strftime("%H:%M:%S.%f")[:-3], + "elapsed_s": round(time.time() - self._start_time, 2), + "type": "decision", + "stage": stage, + "decision": decision, + "reason": reason, + } + if context: + entry["context"] = context + self._timeline.append(entry) + except Exception: + pass + + def write(self, expert_type: str = "unknown", success: bool = False): + """任务结束时写入日志文件。非阻塞。""" + if not self._enabled: + return + try: + self._log_dir.mkdir(parents=True, exist_ok=True) + + record = { + "task_id": self._task_id, + "user_input": self._user_input, + "model": self._model, + "expert_type": expert_type, + "success": success, + "total_elapsed_s": round(time.time() - self._start_time, 2), + "timestamp": datetime.now().isoformat(), + **self._metadata, + "timeline": self._timeline, + } + + filename = f"{self._task_id}_{expert_type}.json" + filepath = self._log_dir / filename + filepath.write_text( + json.dumps(record, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + logger.debug("DetailedLog written: %s", filepath) + except Exception as e: + logger.debug("DetailedLogger write failed (non-blocking): %s", e) diff --git a/kaiwu/cli/formatters.py b/kaiwu/cli/formatters.py index e5a8385..a6970aa 100644 --- a/kaiwu/cli/formatters.py +++ b/kaiwu/cli/formatters.py @@ -94,7 +94,7 @@ try: from importlib.metadata import version as _pkg_version VERSION = _pkg_version("kwcode") except Exception: - VERSION = "1.6.2" + VERSION = "1.7.0" # ── Shadow/重影大字 KAIWU ── _KAIWU_SHADOW = [ diff --git a/kaiwu/core/context.py b/kaiwu/core/context.py index 7d2e176..574ed44 100644 --- a/kaiwu/core/context.py +++ b/kaiwu/core/context.py @@ -110,3 +110,6 @@ class TaskContext: # 审计日志引用(Generator等专家通过此记录LLM调用) _audit_logger: "Any" = None + + # DetailedLogger引用(完整不截断的流水线日志) + _detailed_logger: "Any" = None diff --git a/kaiwu/core/gate.py b/kaiwu/core/gate.py index af61a58..e0c3f66 100644 --- a/kaiwu/core/gate.py +++ b/kaiwu/core/gate.py @@ -263,6 +263,9 @@ class Gate: max_tokens=30, temperature=0.0, ) + # DetailedLogger: 记录 Gate LLM 调用(通过 llm._on_llm_call 已自动记录, + # 这里额外记录解析结果到 logger) + logger.debug("[gate_llm] prompt=%s, raw=%s", prompt[:200], raw[:200]) # 尝试解析JSON响应 json_str = self._extract_json(raw) diff --git a/kaiwu/core/orchestrator.py b/kaiwu/core/orchestrator.py index 909836f..1de6b9f 100644 --- a/kaiwu/core/orchestrator.py +++ b/kaiwu/core/orchestrator.py @@ -45,6 +45,7 @@ from kaiwu.flywheel.strategy_stats import StrategyStats from kaiwu.flywheel.user_pattern_memory import UserPatternMemory from kaiwu.telemetry.client import TelemetryClient from kaiwu.audit.logger import AuditLogger +from kaiwu.audit.detailed_logger import DetailedLogger from kaiwu.core.model_capability import detect_model_tier, STRATEGIES, ModelTier logger = logging.getLogger(__name__) @@ -178,6 +179,28 @@ class PipelineOrchestrator: start_time = time.time() self._audit.start() + # ── DetailedLogger:完整流水线日志 ── + model_name = getattr(self, '_model_name', 'unknown') + self._detailed = DetailedLogger(user_input=user_input, model=model_name) + self._detailed.set_metadata( + project_root=project_root, + gate_result=gate_result, + ) + # 设置 LLM 回调,自动记录每次调用 + if self._detailed.enabled: + def _llm_hook(messages, raw_output, elapsed_ms): + self._detailed.log_llm( + caller="llm_backend", + prompt="", + system="", + raw_output=raw_output, + elapsed_ms=elapsed_ms, + messages=messages, + ) + llm = getattr(self.generator, 'llm', None) + if llm and hasattr(llm, '_on_llm_call'): + llm._on_llm_call = _llm_hook + # 任务级超时看门狗 TASK_TIMEOUT_S = 300 # 单任务最长5分钟 _watchdog_triggered = threading.Event() @@ -201,6 +224,8 @@ class PipelineOrchestrator: ) # 注入审计日志引用,让Generator等专家能记录LLM调用 ctx._audit_logger = self._audit + # 注入 DetailedLogger 引用到 ctx,让 Generator 等专家能直接写入 + ctx._detailed_logger = self._detailed # 模型能力等级注入ctx ctx.model_tier = self._model_tier.value @@ -331,6 +356,10 @@ class PipelineOrchestrator: sequence = EXPERT_SEQUENCES.get(expert_type, ["generator", "verifier"]) self._emit(on_status, "gate", f"任务类型:{expert_type} | 难度:{gate_result.get('difficulty', '?')} | 路由:{ctx.routing_source}") + # DetailedLogger: gate 决策 + self._detailed.log_node("gate", + {"user_input": user_input[:500]}, + {"expert_type": expert_type, "difficulty": gate_result.get("difficulty", "?"), "routing_source": ctx.routing_source, "confidence": gate_result.get("confidence", 0), "sequence": sequence}) # ── MoE专家选择(已移除独立Expert类,统一走pipeline) ── moe_expert = self._select_moe_expert(ctx, expert_type) @@ -468,6 +497,11 @@ class PipelineOrchestrator: self._emit(on_status, "retry", f"第{ctx.retry_count}次尝试失败:{error_detail[:100]}") self.bus.emit("retry", {"count": ctx.retry_count, "error": error_detail[:100]}) + # DetailedLogger: 记录重试决策 + self._detailed.log_decision("retry", + decision=f"retry #{ctx.retry_count}, strategy={retry_strategy.get('sequence', [])}", + reason=f"error_type={current_error_type}, detail={error_detail[:200]}", + context={"error_type_streak": ctx._error_type_streak, "attempt_record": attempt_record}) # 第2次重试前反思:让LLM分析失败原因 if ctx.retry_count == 1 and ctx.verifier_output and ctx.generator_output: @@ -566,6 +600,8 @@ class PipelineOrchestrator: ctx.generator_output = {"explanation": "我是KWCode,专注于代码任务。", "patches": []} result = {"passed": True} elapsed = time.time() - start_time + self._detailed.log_node("chat", {"user_input": ctx.user_input[:500]}, {"explanation": (ctx.generator_output or {}).get("explanation", "")[:500]}) + self._detailed.write(expert_type="chat", success=True) return { "success": True, "context": ctx, @@ -723,6 +759,8 @@ class PipelineOrchestrator: self._record_flywheel(ctx, gate_result, True) # 审计日志 self._audit.write(ctx, elapsed, True, getattr(self, '_model_name', 'unknown')) + # DetailedLogger: 写入成功日志 + self._detailed.write(expert_type=gate_result.get("expert_type", "unknown"), success=True) return { "success": True, "context": ctx, @@ -763,6 +801,8 @@ class PipelineOrchestrator: self._record_flywheel(ctx, gate_result, False) # 审计日志 self._audit.write(ctx, elapsed, False, getattr(self, '_model_name', 'unknown')) + # DetailedLogger: 写入失败日志 + self._detailed.write(expert_type=gate_result.get("expert_type", "unknown"), success=False) return { "success": False, "context": ctx, @@ -779,6 +819,7 @@ class PipelineOrchestrator: search_result = self._search_subagent.search(ctx, self._manifest) if not search_result or not search_result.get("relevant_files"): self._emit(on_status, "locator_fail", "定位失败") + self._detailed.log_node("locator", {"user_input": ctx.user_input[:200]}, {"result": None}, detail="定位失败") return False # 将干净结果传给ctx(Generator只看到这些) ctx.locator_output = { @@ -795,17 +836,26 @@ class PipelineOrchestrator: funcs = search_result["relevant_functions"] func_str = ', '.join(funcs[:3]) if funcs else "(文件级修改)" self._emit(on_status, "locator_done", f"文件:{', '.join(files[:3])} | 函数:{func_str}") + # DetailedLogger: locator 节点 + self._detailed.log_node("locator", + {"user_input": ctx.user_input[:200], "gap_type": str(ctx.gap.gap_type.value) if ctx.gap and hasattr(ctx.gap, 'gap_type') else ""}, + {"files": files, "functions": funcs, "method": search_result.get("method", ""), "upstream_constraints": search_result.get("upstream_constraints", "")[:500]}) elif step == "generator": self._emit(on_status, "generator", "生成patch...") result = self.generator.run(ctx) if not result: self._emit(on_status, "generator_fail", "生成失败") + self._detailed.log_node("generator", {"files": (ctx.locator_output or {}).get("relevant_files", [])}, {"result": None}, detail="生成失败") return False n_patches = len(result.get("patches", [])) self._emit(on_status, "generator_done", f"生成{n_patches}个patch") # 用新patch更新manifest(跨文件追踪) self._manifest.update(result.get("patches", [])) + # DetailedLogger: generator 节点 + self._detailed.log_node("generator", + {"files": (ctx.locator_output or {}).get("relevant_files", []), "functions": (ctx.locator_output or {}).get("relevant_functions", [])}, + {"patch_count": n_patches, "files_modified": [p.get("file", "") for p in result.get("patches", [])], "explanation": result.get("explanation", "")}) elif step == "verifier": self._emit(on_status, "verifier", "验证中...") @@ -826,15 +876,22 @@ class PipelineOrchestrator: "error_message": detail[:200], "failed_tests": [], } + self._detailed.log_node("verifier", {"patch_count": len((ctx.generator_output or {}).get("patches", []))}, {"passed": False, "error_type": "contract_violation", "detail": detail[:300]}) return False result = self.verifier.run(ctx) if not result or not result.get("passed"): detail = result.get("error_detail", "unknown") if result else "no result" self._emit(on_status, "verifier_fail", f"验证失败:{detail[:80]}") + self._detailed.log_node("verifier", + {"patch_count": len((ctx.generator_output or {}).get("patches", []))}, + {"passed": False, "error_type": result.get("error_type", "") if result else "", "error_detail": detail[:1000], "tests_passed": result.get("tests_passed", 0) if result else 0, "tests_total": result.get("tests_total", 0) if result else 0}) return False tp = result.get("tests_passed", 0) tt = result.get("tests_total", 0) self._emit(on_status, "verifier_done", f"语法OK | 测试:{tp}/{tt}") + self._detailed.log_node("verifier", + {"patch_count": len((ctx.generator_output or {}).get("patches", []))}, + {"passed": True, "tests_passed": tp, "tests_total": tt}) elif step == "office": self._emit(on_status, "office", "生成Office文档...") diff --git a/kaiwu/experts/generator.py b/kaiwu/experts/generator.py index 5009335..c5de489 100644 --- a/kaiwu/experts/generator.py +++ b/kaiwu/experts/generator.py @@ -217,11 +217,12 @@ class GeneratorExpert: if self._is_test_generation_task(ctx): return self._run_test_generation(ctx, files) - # ── whole_file scope: 存根实现,LLM返回完整文件 ── + # ── whole_file scope: 存根实现 ── + # Sub-task decomposition: 逐函数独立实现,每个函数独立context if ctx.gap and hasattr(ctx.gap, 'gap_type'): from kaiwu.core.gap_detector import GapType if ctx.gap.gap_type in (GapType.NOT_IMPLEMENTED, GapType.STUB_RETURNS_NONE): - return self._run_whole_file(ctx, files) + return self._run_stub_decomposed(ctx, files, funcs) # For each file+function pair, extract original and generate modified # Deduplicate: only patch each (file, function) once @@ -367,7 +368,8 @@ class GeneratorExpert: return any(kw in lower for kw in _WEB_KEYWORDS) def _generate_modified(self, ctx: TaskContext, fpath: str, original: str, task_desc: str) -> Optional[str]: - """Ask LLM to generate modified code. Hashline primary, full-function fallback.""" + """Ask LLM to generate modified code. Hashline primary, full-function fallback. + Bounded context: only pass current function + relevant failing tests.""" search_ctx = "" if ctx.search_results: search_ctx = f"参考资料:\n{ctx.search_results}" @@ -392,38 +394,55 @@ class GeneratorExpert: if ctx.doc_context: prompt += f"\n\n## 相关文档参考\n{ctx.doc_context[:800]}" - # 注入初始测试失败信息(让LLM第一次就看到具体报错) + # ── Bounded context: 只注入与当前函数相关的 failing tests ── + # 从 structured_failures 中筛选与当前函数/文件相关的条目 initial_failure = getattr(ctx, 'initial_test_failure', '') - - # 结构化失败信息(精确告诉LLM每个测试为什么失败)— 首次和重试都注入 structured = (ctx.verifier_output or {}).get("structured_failures", []) if not structured and initial_failure: from kaiwu.core.test_parser import parse_test_failures structured = parse_test_failures(initial_failure) - if structured: - lines = ["## 必须修复的测试失败(精确信息)"] - for f in structured[:8]: - name = f.get("test_name", "?") - expected = f.get("expected", "") - actual = f.get("actual", "") - snippet = f.get("snippet", "") - err_type = f.get("error_type", "") - if expected and actual: - lines.append(f"- {name}: 期望 {expected},实际 {actual}") - elif err_type and snippet: - lines.append(f"- {name}: {err_type}: {snippet[:120]}") - elif snippet: - lines.append(f"- {name}: {snippet[:120]}") - else: - lines.append(f"- {name}") - prompt += "\n\n" + "\n".join(lines) - elif initial_failure and ctx.retry_count == 0: - # fallback: 没解析出结构化信息时给raw output - prompt += f"\n\n## 当前测试失败\n{initial_failure[:800]}" - # Inject retry_hint if available + if structured: + # 提取当前函数名(从 original 的第一行 def/class 获取) + current_func = self._extract_func_name_from_code(original) + # 筛选与当前函数/文件相关的失败 + relevant_failures = self._filter_relevant_failures(structured, current_func, fpath) + if relevant_failures: + lines = ["## 必须修复的测试失败(精确信息)"] + for f in relevant_failures[:5]: + name = f.get("test_name", "?") + expected = f.get("expected", "") + actual = f.get("actual", "") + snippet = f.get("snippet", "") + err_type = f.get("error_type", "") + if expected and actual: + lines.append(f"- {name}: 期望 {expected},实际 {actual}") + elif err_type and snippet: + lines.append(f"- {name}: {err_type}: {snippet[:120]}") + elif snippet: + lines.append(f"- {name}: {snippet[:120]}") + else: + lines.append(f"- {name}") + prompt += "\n\n" + "\n".join(lines) + elif not relevant_failures and structured: + # 没有精确匹配时,给前3条作为上下文(但不是全部) + lines = ["## 相关测试失败"] + for f in structured[:3]: + name = f.get("test_name", "?") + snippet = f.get("snippet", "") + lines.append(f"- {name}: {snippet[:100]}" if snippet else f"- {name}") + prompt += "\n\n" + "\n".join(lines) + elif initial_failure and ctx.retry_count == 0: + # fallback: 没解析出结构化信息时给精简的 raw output + prompt += f"\n\n## 当前测试失败\n{initial_failure[:500]}" + + # Inject retry_hint: 只传一句话总结,不传完整历史 if ctx.retry_hint: - prompt += f"\n\n## 重试提示\n{ctx.retry_hint}" + # 截断 retry_hint,避免注入过多历史 + hint = ctx.retry_hint + if len(hint) > 300: + hint = hint[:300] + "..." + prompt += f"\n\n## 重试提示\n{hint}" system = self._build_system(ctx) @@ -449,6 +468,41 @@ class GeneratorExpert: logger.warning("Generator: all candidates identical to original or empty") return None + @staticmethod + def _extract_func_name_from_code(code: str) -> str: + """从代码片段中提取函数/类名。""" + for line in code.split("\n"): + stripped = line.strip() + if stripped.startswith("def "): + # def func_name(...) + name = stripped[4:].split("(")[0].strip() + return name + if stripped.startswith("class "): + name = stripped[6:].split("(")[0].split(":")[0].strip() + return name + return "" + + @staticmethod + def _filter_relevant_failures(failures: list, func_name: str, file_path: str) -> list: + """筛选与当前函数/文件相关的测试失败。""" + if not func_name: + return failures[:5] # 无法确定函数名时返回前5条 + + relevant = [] + fname_lower = func_name.lower() + fpath_base = file_path.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] if file_path else "" + + for f in failures: + test_name = f.get("test_name", "").lower() + snippet = f.get("snippet", "").lower() + # 测试名包含函数名,或 snippet 中提到函数名/文件名 + if (fname_lower in test_name or + fname_lower in snippet or + (fpath_base and fpath_base.lower() in snippet)): + relevant.append(f) + + return relevant + def _try_hashline(self, ctx: TaskContext, fpath: str, original: str, task_desc: str, search_ctx: str) -> Optional[str]: """Try Hashline anchor-based editing. Returns modified code or None.""" @@ -853,11 +907,22 @@ class GeneratorExpert: output_tokens=output_tokens, engineering_actions={}, ) + # DetailedLogger: 完整不截断记录 + detailed = getattr(ctx, '_detailed_logger', None) + if detailed and detailed.enabled: + detailed.log_llm( + caller=caller, + prompt=prompt, + system=system, + raw_output=raw_output or '', + tokens={"input": len(prompt) // 4 + len(system) // 4, "output": len(raw_output or '') // 4}, + ) except Exception: pass # 非阻塞 def _run_whole_file(self, ctx: TaskContext, files: list[str]) -> Optional[dict]: - """whole_file scope:LLM返回完整文件内容,直接write_file,不走apply_patch。""" + """whole_file scope:LLM返回完整文件内容,直接write_file,不走apply_patch。 + Fallback for _run_stub_decomposed when decomposition fails.""" patches = [] explanation_parts = [] @@ -917,3 +982,178 @@ class GeneratorExpert: } ctx.generator_output = result return result + + def _run_stub_decomposed(self, ctx: TaskContext, files: list[str], funcs: list[str]) -> Optional[dict]: + """ + Sub-task decomposition for stub tasks. + Instead of asking LLM to implement all pass functions at once, + decompose into per-function subtasks with independent bounded context. + Each function gets its own LLM call with only its code + relevant tests. + Falls back to _run_whole_file if decomposition fails. + """ + patches = [] + explanation_parts = [] + + for fpath in files[:3]: + if "test" in fpath.lower(): + continue + + # Read file content + if self.tools: + content = self.tools.read_file(fpath) + else: + content = ctx.relevant_code_snippets.get(fpath, "") + if not content or content.startswith("[ERROR]"): + continue + + # Find all stub functions (pass/... body) + stub_funcs = self._find_stub_functions(content) + if not stub_funcs: + continue + + # If only 1-2 stubs, or file is small, use whole_file (simpler) + if len(stub_funcs) <= 2 or len(content.split("\n")) < 50: + result = self._run_whole_file(ctx, [fpath]) + if result: + return result + continue + + # ── Per-function decomposition ── + initial_failure = getattr(ctx, 'initial_test_failure', '') + structured_failures = (ctx.verifier_output or {}).get("structured_failures", []) + if not structured_failures and initial_failure: + from kaiwu.core.test_parser import parse_test_failures + structured_failures = parse_test_failures(initial_failure) + + file_patches = [] + for func_name, func_code in stub_funcs: + # Build bounded context for this single function + relevant_tests = self._filter_relevant_failures( + structured_failures, func_name, fpath + ) + + test_info = "" + if relevant_tests: + lines = [] + for f in relevant_tests[:3]: + name = f.get("test_name", "?") + expected = f.get("expected", "") + actual = f.get("actual", "") + snippet = f.get("snippet", "") + if expected and actual: + lines.append(f"- {name}: 期望 {expected},实际 {actual}") + elif snippet: + lines.append(f"- {name}: {snippet[:120]}") + else: + lines.append(f"- {name}") + test_info = "\n\n## 相关测试\n" + "\n".join(lines) + + prompt = ( + f"任务:{ctx.user_input[:200]}\n\n" + f"实现以下函数(来自 {fpath}):\n" + f"```\n{func_code}\n```\n\n" + f"只输出实现后的完整函数代码(从def开始)。\n" + f"保持原始缩进和签名不变,只替换pass为实现。\n" + f"纯代码,无markdown,无解释。" + f"{test_info}" + ) + + if ctx.search_results: + prompt += f"\n\n参考资料:\n{ctx.search_results[:500]}" + + system = self._build_system(ctx) + + raw = self.llm.generate(prompt=prompt, system=system, max_tokens=2048, temperature=0.0) + self._log_llm_call(ctx, f"generator_stub_{func_name}", prompt, system, raw) + modified = self._clean_code_output(raw) + + if modified and modified.strip() != func_code.strip(): + modified = modified.strip("\n") + modified = self._align_indentation(func_code, modified) + file_patches.append({ + "file": fpath, + "original": func_code, + "modified": modified, + }) + explanation_parts.append(f"{fpath}:{func_name}") + + if file_patches: + patches.extend(file_patches) + + if not patches: + logger.debug("Stub decomposition produced no patches, falling back to whole_file") + return self._run_whole_file(ctx, files) + + result = { + "patches": patches, + "explanation": f"Stub impl: {', '.join(explanation_parts)}", + } + ctx.generator_output = result + return result + + @staticmethod + def _find_stub_functions(content: str) -> list[tuple[str, str]]: + """ + Find all stub functions (body is just 'pass', '...', or 'return None') in file content. + Returns list of (func_name, full_function_code) tuples. + """ + lines = content.split("\n") + stubs = [] + i = 0 + while i < len(lines): + line = lines[i] + stripped = line.lstrip() + if stripped.startswith("def "): + func_name = stripped[4:].split("(")[0].strip() + indent_level = len(line) - len(stripped) + start_idx = i + + # Find end of function + end_idx = i + 1 + while end_idx < len(lines): + l = lines[end_idx] + if l.strip() == "": + end_idx += 1 + continue + current_indent = len(l) - len(l.lstrip()) + if current_indent <= indent_level and l.strip(): + break + end_idx += 1 + + func_code = "\n".join(lines[start_idx:end_idx]).rstrip() + + # Check if body is stub (skip docstrings and comments) + in_docstring = False + clean_body = [] + for bl in lines[start_idx + 1:end_idx]: + s = bl.strip() + if not s: + continue + if s.startswith('"""') or s.startswith("'''"): + if s.count('"""') >= 2 or s.count("'''") >= 2: + continue # single-line docstring + in_docstring = not in_docstring + continue + if in_docstring: + continue + if s.startswith("#"): + continue + clean_body.append(s) + + is_stub = ( + len(clean_body) == 0 or + (len(clean_body) == 1 and clean_body[0] in ( + "pass", "...", "return None", + "raise NotImplementedError", + "raise NotImplementedError()", + )) + ) + + if is_stub: + stubs.append((func_name, func_code)) + + i = end_idx + else: + i += 1 + + return stubs diff --git a/kaiwu/llm/llama_backend.py b/kaiwu/llm/llama_backend.py index 203fe62..d5a904f 100644 --- a/kaiwu/llm/llama_backend.py +++ b/kaiwu/llm/llama_backend.py @@ -73,6 +73,8 @@ class LLMBackend: self._total_output_tokens: int = 0 self._call_count: int = 0 self._token_budget: int = 0 # 0 = unlimited + # DetailedLogger 回调:每次 LLM 调用后触发(由 orchestrator 设置) + self._on_llm_call = None # Callable(caller, messages, raw_output, tokens, elapsed_ms) # Prefer native llama.cpp if model_path provided and library available if model_path and HAS_LLAMA_CPP: @@ -122,7 +124,14 @@ class LLMBackend: # If it's not localhost at all, assume OpenAI-compatible if "localhost" not in url_lower and "127.0.0.1" not in url_lower: return True - return False + # For localhost on non-standard ports, probe /api/tags to detect Ollama + try: + resp = httpx.get(f"{url.rstrip('/')}/api/tags", timeout=3) + if resp.status_code == 200 and "models" in resp.text: + return False # It's Ollama + except Exception: + pass + return True # Not Ollama, assume OpenAI-compatible @property def token_usage(self) -> dict: @@ -211,6 +220,20 @@ class LLMBackend: self._last_elapsed = _time.perf_counter() - t0 if self._tps_estimator: self._tps_estimator.record(result, self._last_elapsed) + # DetailedLogger 回调 + if self._on_llm_call: + try: + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + self._on_llm_call( + messages=messages, + raw_output=result, + elapsed_ms=self._last_elapsed * 1000, + ) + except Exception: + pass return result def _generate_native( @@ -281,6 +304,16 @@ class LLMBackend: self._last_elapsed = _time.perf_counter() - t0 if self._tps_estimator: self._tps_estimator.record(result, self._last_elapsed) + # DetailedLogger 回调 + if self._on_llm_call: + try: + self._on_llm_call( + messages=messages, + raw_output=result, + elapsed_ms=self._last_elapsed * 1000, + ) + except Exception: + pass return result def _chat_ollama( diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 872eb86..b404e3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "kwcode" -version = "1.6.2" +version = "1.7.0" description = "KwCode - Local-model coding agent with MoE expert pipeline" requires-python = ">=3.10" readme = "README.md"