| from __future__ import annotations |
|
|
| import importlib.util |
| from pathlib import Path |
| from typing import Any |
| import json |
| import os |
| import re |
| import shlex |
| import shutil |
| import subprocess |
| import traceback |
| import requests |
|
|
| from ..mcp_router import MCPToolRouter |
| from ..schemas import ExperimentReport, FailureMode |
| from ..shared_memory import SharedKnowledgeSpace |
|
|
|
|
| class BioinfoV1Executor: |
| """Execution engine: plan task -> call MCP servers -> publish structured report.""" |
|
|
| def __init__( |
| self, |
| memory: SharedKnowledgeSpace, |
| default_results_root: str | Path, |
| project_root: str | Path, |
| execution_backend: str = "docker", |
| ): |
| self.memory = memory |
| self.default_results_root = Path(default_results_root) |
| self.default_results_root.mkdir(parents=True, exist_ok=True) |
| self.project_root = Path(project_root) |
| self.router = MCPToolRouter(project_root=project_root) |
| self.execution_backend = execution_backend |
|
|
| @staticmethod |
| def _append_log(log_file: Path, message: str) -> None: |
| with log_file.open("a", encoding="utf-8") as lf: |
| lf.write(message.rstrip() + "\n") |
|
|
| @staticmethod |
| def _jsonable(value: Any) -> Any: |
| if isinstance(value, Path): |
| return str(value) |
| if isinstance(value, dict): |
| return {str(k): BioinfoV1Executor._jsonable(v) for k, v in value.items()} |
| if isinstance(value, list): |
| return [BioinfoV1Executor._jsonable(v) for v in value] |
| if isinstance(value, tuple): |
| return [BioinfoV1Executor._jsonable(v) for v in value] |
| return value |
|
|
| @staticmethod |
| def _read_log_tail(log_file: Path, max_lines: int = 200) -> str: |
| try: |
| lines = log_file.read_text(encoding="utf-8", errors="replace").splitlines() |
| return "\n".join(lines[-max_lines:]) |
| except Exception: |
| return "" |
|
|
| @staticmethod |
| def _pick_trimmed_pair(trim_dir: Path) -> tuple[Path | None, Path | None]: |
| r1_patterns = [ |
| "*_val_1.fq.gz", |
| "*_val_1.fq", |
| "*_R1_val_1.fq.gz", |
| "*_1_val_1.fq.gz", |
| "*_fastp_R1.fastq", |
| "*_fastp_R1.fastq.gz", |
| ] |
| r2_patterns = [ |
| "*_val_2.fq.gz", |
| "*_val_2.fq", |
| "*_R2_val_2.fq.gz", |
| "*_2_val_2.fq.gz", |
| "*_fastp_R2.fastq", |
| "*_fastp_R2.fastq.gz", |
| ] |
| r1_candidates: list[Path] = [] |
| r2_candidates: list[Path] = [] |
| for pattern in r1_patterns: |
| r1_candidates.extend(sorted(trim_dir.glob(pattern))) |
| for pattern in r2_patterns: |
| r2_candidates.extend(sorted(trim_dir.glob(pattern))) |
| return (r1_candidates[-1] if r1_candidates else None, r2_candidates[-1] if r2_candidates else None) |
|
|
| def _plan_task( |
| self, |
| task: str, |
| task_scope: str, |
| pipeline_config: dict[str, Any] | None, |
| ) -> list[dict[str, Any]]: |
| """ |
| Plan execution in a Biomni-like style: |
| 1) understand scope |
| 2) choose candidate tools |
| 3) execute step-by-step with artifacts passed forward |
| """ |
| cfg_tools = (pipeline_config or {}).get("tools") or [] |
|
|
| if task_scope == "first_pipeline": |
| task_lower = task.lower() |
| wants_alignment = any( |
| kw in task_lower |
| for kw in ("align", "alignment", "map", "mapping", "variant", "snp", "bam") |
| ) |
| benchmark_repro_mode = any( |
| kw in task_lower |
| for kw in ("code repository", "original open-source code", "reproduce paper", "reproducibility") |
| ) |
| base_plan = [ |
| { |
| "name": "qc_raw", |
| "description": "Quality control on raw reads", |
| "candidates": cfg_tools or ["fastqc", "fastp"], |
| }, |
| { |
| "name": "trim", |
| "description": "Adapter/quality trimming", |
| "candidates": cfg_tools or ["trim_galore", "cutadapt", "trimmomatic", "fastp"], |
| }, |
| { |
| "name": "align", |
| "description": "Read alignment to reference index", |
| "candidates": cfg_tools or ["bowtie2", "bwa", "hisat2", "star", "minimap2"], |
| }, |
| { |
| "name": "qc_trimmed", |
| "description": "Quality control after trimming", |
| "candidates": cfg_tools or ["fastqc", "qualimap"], |
| }, |
| { |
| "name": "aggregate", |
| "description": "Aggregate reports", |
| "candidates": cfg_tools or ["multiqc"], |
| }, |
| ] |
| if not wants_alignment: |
| base_plan = [s for s in base_plan if s["name"] != "align"] |
|
|
| if benchmark_repro_mode: |
| base_plan.extend( |
| [ |
| { |
| "name": "source_clone", |
| "description": "Clone original paper source code repository", |
| "local_executor": "source_clone", |
| "candidates": [], |
| }, |
| { |
| "name": "source_execute", |
| "description": "Run reproducibility-oriented source code execution attempts", |
| "local_executor": "source_execute", |
| "candidates": [], |
| }, |
| { |
| "name": "summarize_repro", |
| "description": "Summarize reproducibility with deviations and evidence", |
| "local_executor": "summarize_repro", |
| "candidates": [], |
| }, |
| ] |
| ) |
| return base_plan |
|
|
| |
| if cfg_tools: |
| return [ |
| { |
| "name": f"step_{i+1}", |
| "description": f"Configured execution step for {tool}", |
| "candidates": [tool], |
| } |
| for i, tool in enumerate(cfg_tools) |
| ] |
|
|
| |
| return [ |
| { |
| "name": "generic_step", |
| "description": f"Generic execution for task: {task}", |
| "candidates": ["fastqc"], |
| } |
| ] |
|
|
| def _route_or_generate(self, tool_name: str, log_file: Path): |
| if self.router.has_tool(tool_name): |
| return self.router.resolve(tool_name) |
|
|
| self._append_log(log_file, f"[planner] MCP tool missing: {tool_name}, invoking converter...") |
| self._try_generate_mcp_server(tool_name, log_file) |
| self.router.refresh() |
| if self.router.has_tool(tool_name): |
| return self.router.resolve(tool_name) |
| return None |
|
|
| def _try_generate_mcp_server(self, tool_name: str, log_file: Path) -> None: |
| converter_path = self.project_root / "src" / "bioinfomcp_converter.py" |
| if not converter_path.exists(): |
| self._append_log(log_file, f"[converter] converter not found: {converter_path}") |
| return |
|
|
| try: |
| spec = importlib.util.spec_from_file_location("bioinfomcp_converter", str(converter_path)) |
| if spec is None or spec.loader is None: |
| raise RuntimeError("Failed to load converter module spec.") |
| module = importlib.util.module_from_spec(spec) |
| spec.loader.exec_module(module) |
| converter_cls = getattr(module, "BioinfoMCP", None) |
| if converter_cls is None: |
| raise RuntimeError("BioinfoMCP class not found in converter.") |
|
|
| converter = converter_cls(model="openai") |
| ok, error_msg, code = converter.autogenerate_mcp_tool( |
| tool_name=tool_name, |
| manual="--help", |
| run_help_command=True, |
| ) |
| if not ok or not code: |
| raise RuntimeError(f"Converter failed: {error_msg}") |
|
|
| mcp_dir = self.project_root / "mcp-servers" / f"mcp_{tool_name}" / "app" |
| mcp_dir.mkdir(parents=True, exist_ok=True) |
| server_file = mcp_dir / f"{tool_name}_server.py" |
| server_file.write_text(self._wrap_generated_mcp_code(code), encoding="utf-8") |
| self._append_log(log_file, f"[converter] generated MCP server: {server_file}") |
| except Exception as exc: |
| self._append_log(log_file, f"[converter] generation failed for {tool_name}: {exc}") |
|
|
| @staticmethod |
| def _wrap_generated_mcp_code(code: str) -> str: |
| if "FastMCP" in code and "mcp = FastMCP()" in code: |
| if "if __name__ == '__main__':" in code or "if __name__ == \"__main__\":" in code: |
| return code |
| return f"{code}\n\nif __name__ == '__main__':\n mcp.run()\n" |
|
|
| return ( |
| "from fastmcp import FastMCP\n" |
| "mcp = FastMCP()\n\n" |
| f"{code}\n\n" |
| "if __name__ == '__main__':\n" |
| " mcp.run()\n" |
| ) |
|
|
| @staticmethod |
| def _load_tool_callable(server_script: Path, function_name: str): |
| mod_name = f"mcp_module_{server_script.stem}_{abs(hash(str(server_script)))}" |
| spec = importlib.util.spec_from_file_location(mod_name, str(server_script)) |
| if spec is None or spec.loader is None: |
| raise RuntimeError(f"Cannot load module for {server_script}") |
| module = importlib.util.module_from_spec(spec) |
| spec.loader.exec_module(module) |
| func = getattr(module, function_name, None) |
| if func is None: |
| raise RuntimeError(f"Function '{function_name}' not found in {server_script}") |
| return func |
|
|
| def _invoke_mcp_tool( |
| self, |
| route, |
| kwargs: dict[str, Any], |
| log_file: Path, |
| ) -> dict[str, Any]: |
| self._append_log( |
| log_file, |
| f"[toolcall] {route.tool_name}.{route.function_name} script={route.server_script} kwargs={json.dumps({k: str(v) for k, v in kwargs.items()}, ensure_ascii=True)}", |
| ) |
| if self.execution_backend == "docker": |
| return self._invoke_mcp_tool_docker(route, kwargs=kwargs, log_file=log_file) |
|
|
| try: |
| tool_fn = self._load_tool_callable(route.server_script, route.function_name) |
| result = tool_fn(**kwargs) |
| if not isinstance(result, dict): |
| result = {"raw_result": result} |
| ok = "error" not in result |
| self._append_log(log_file, f"[toolcall] status={'ok' if ok else 'error'}") |
| return {"ok": ok, "result": result} |
| except FileNotFoundError as exc: |
| tb = traceback.format_exc() |
| self._append_log(log_file, f"[toolcall] FileNotFoundError: {exc}\n{tb}") |
| return {"ok": False, "result": {"error": f"FileNotFoundError: {exc}", "traceback": tb}} |
| except Exception as exc: |
| tb = traceback.format_exc() |
| self._append_log(log_file, f"[toolcall] exception: {exc}\n{tb}") |
| return {"ok": False, "result": {"error": str(exc), "traceback": tb}} |
|
|
| def _invoke_mcp_tool_docker(self, route, kwargs: dict[str, Any], log_file: Path) -> dict[str, Any]: |
| if shutil.which("docker") is None: |
| return { |
| "ok": False, |
| "result": {"error": "docker_not_found", "hint": "Install Docker or switch execution_backend=python"}, |
| } |
|
|
| run_dir = log_file.parent |
| payload_file = run_dir / f"_mcp_payload_{route.function_name}.json" |
| runner_file = run_dir / "_mcp_docker_runner.py" |
| payload = { |
| "server_script": str(route.server_script), |
| "function_name": route.function_name, |
| "kwargs": self._jsonable(kwargs), |
| } |
| payload_file.write_text(json.dumps(payload, ensure_ascii=True), encoding="utf-8") |
| runner_file.write_text(self._docker_runner_script(), encoding="utf-8") |
|
|
| mount_root = self.project_root.parent |
| docker_cmd = [ |
| "docker", |
| "run", |
| "--rm", |
| "-v", |
| f"{mount_root}:{mount_root}", |
| "-w", |
| str(mount_root), |
| route.image_name, |
| "python", |
| str(runner_file), |
| str(payload_file), |
| ] |
| self._append_log(log_file, f"[toolcall-docker] cmd={' '.join(docker_cmd)}") |
|
|
| completed = subprocess.run(docker_cmd, capture_output=True, text=True) |
| stdout = completed.stdout or "" |
| stderr = completed.stderr or "" |
| if completed.returncode != 0: |
| self._append_log(log_file, f"[toolcall-docker] failed rc={completed.returncode}\n{stderr}") |
| return { |
| "ok": False, |
| "result": { |
| "error": f"docker_run_failed rc={completed.returncode}", |
| "stdout": stdout, |
| "stderr": stderr, |
| "image": route.image_name, |
| "hint": ( |
| f"Ensure image '{route.image_name}' exists/builds, " |
| f"or run with execution_backend=python." |
| ), |
| }, |
| } |
|
|
| try: |
| result = json.loads(stdout.strip() or "{}") |
| if not isinstance(result, dict): |
| result = {"raw_result": result} |
| except Exception: |
| result = {"raw_stdout": stdout, "raw_stderr": stderr} |
|
|
| ok = "error" not in result |
| return {"ok": ok, "result": result} |
|
|
| @staticmethod |
| def _docker_runner_script() -> str: |
| return """from __future__ import annotations |
| import importlib.util |
| import inspect |
| import json |
| import sys |
| from pathlib import Path |
| from typing import Any, get_args, get_origin, Union |
| |
| |
| def to_jsonable(v: Any): |
| if isinstance(v, Path): |
| return str(v) |
| if isinstance(v, dict): |
| return {str(k): to_jsonable(val) for k, val in v.items()} |
| if isinstance(v, list): |
| return [to_jsonable(x) for x in v] |
| if isinstance(v, tuple): |
| return [to_jsonable(x) for x in v] |
| return v |
| |
| |
| def is_path_type(tp: Any) -> bool: |
| return tp is Path |
| |
| |
| def convert_value(value: Any, ann: Any) -> Any: |
| if ann is inspect._empty: |
| return value |
| origin = get_origin(ann) |
| if origin in (list, tuple): |
| args = get_args(ann) |
| inner = args[0] if args else Any |
| if isinstance(value, list): |
| return [convert_value(v, inner) for v in value] |
| return value |
| if origin is Union: |
| for a in get_args(ann): |
| if a is type(None): |
| continue |
| try: |
| return convert_value(value, a) |
| except Exception: |
| pass |
| return value |
| if is_path_type(ann): |
| return Path(value) if value is not None else value |
| return value |
| |
| |
| def main(): |
| payload_path = Path(sys.argv[1]) |
| payload = json.loads(payload_path.read_text(encoding="utf-8")) |
| server_script = payload["server_script"] |
| function_name = payload["function_name"] |
| kwargs = payload.get("kwargs", {}) |
| |
| spec = importlib.util.spec_from_file_location("mcp_runtime_mod", server_script) |
| if spec is None or spec.loader is None: |
| print(json.dumps({"error": f"cannot_load_module:{server_script}"})) |
| sys.exit(0) |
| mod = importlib.util.module_from_spec(spec) |
| spec.loader.exec_module(mod) |
| fn = getattr(mod, function_name, None) |
| if fn is None: |
| print(json.dumps({"error": f"function_not_found:{function_name}"})) |
| sys.exit(0) |
| |
| sig = inspect.signature(fn) |
| call_kwargs = {} |
| for k, v in kwargs.items(): |
| if k in sig.parameters: |
| call_kwargs[k] = convert_value(v, sig.parameters[k].annotation) |
| else: |
| call_kwargs[k] = v |
| |
| try: |
| result = fn(**call_kwargs) |
| print(json.dumps(to_jsonable(result), ensure_ascii=True)) |
| except Exception as exc: |
| import traceback |
| print(json.dumps({"error": str(exc), "traceback": traceback.format_exc()}, ensure_ascii=True)) |
| |
| |
| if __name__ == "__main__": |
| main() |
| """ |
|
|
| def _build_step_kwargs( |
| self, |
| step_name: str, |
| route_function: str, |
| context: dict[str, Any], |
| ) -> dict[str, Any]: |
| run_dir: Path = context["run_dir"] |
| r1: Path = context["r1"] |
| r2: Path = context["r2"] |
| threads: int = context["threads"] |
| quality_cutoff: int = context["quality_cutoff"] |
| index_base = context["index_base"] |
| artifacts = context["artifacts"] |
|
|
| if step_name == "qc_raw": |
| outdir = run_dir / "01_fastqc_raw" |
| outdir.mkdir(parents=True, exist_ok=True) |
| return {"input_files": [r1, r2], "outdir": outdir, "threads": threads} |
|
|
| if step_name == "trim": |
| outdir = run_dir / "02_trim" |
| outdir.mkdir(parents=True, exist_ok=True) |
| if route_function == "fastp": |
| out1 = outdir / f"{r1.stem}_fastp_R1.fastq" |
| out2 = outdir / f"{r2.stem}_fastp_R2.fastq" |
| json_report = str(outdir / "fastp.json") |
| html_report = str(outdir / "fastp.html") |
| return { |
| "in1": r1, |
| "in2": r2, |
| "out1": out1, |
| "out2": out2, |
| "qualified_quality_phred": quality_cutoff, |
| "cut_mean_quality": quality_cutoff, |
| "cut_right": True, |
| "thread": threads, |
| "json": json_report, |
| "html": html_report, |
| "report_title": "fastp report (BioClawMCP)", |
| } |
|
|
| return { |
| "input_files": [r1, r2], |
| "paired": True, |
| "quality": quality_cutoff, |
| "output_dir": outdir, |
| "cores": threads, |
| } |
|
|
| if step_name == "align": |
| align_dir = run_dir / "03_align" |
| align_dir.mkdir(parents=True, exist_ok=True) |
| sam_out = align_dir / "alignment.sam" |
| artifacts["sam"] = sam_out |
| trimmed_r1 = artifacts.get("trimmed_r1", r1) |
| trimmed_r2 = artifacts.get("trimmed_r2", r2) |
| if route_function == "bowtie2_align": |
| return { |
| "index_base": str(index_base), |
| "mate1_files": str(trimmed_r1), |
| "mate2_files": str(trimmed_r2), |
| "sam_output": sam_out, |
| "threads": threads, |
| } |
| |
| return { |
| "input_files": [trimmed_r1, trimmed_r2], |
| "reference_index_base": str(index_base), |
| "output_dir": align_dir, |
| "threads": threads, |
| } |
|
|
| if step_name == "qc_trimmed": |
| outdir = run_dir / "04_fastqc_trimmed" |
| outdir.mkdir(parents=True, exist_ok=True) |
| trimmed_r1 = artifacts.get("trimmed_r1", r1) |
| trimmed_r2 = artifacts.get("trimmed_r2", r2) |
| return {"input_files": [trimmed_r1, trimmed_r2], "outdir": outdir, "threads": threads} |
|
|
| if step_name == "aggregate": |
| outdir = run_dir / "05_multiqc" |
| outdir.mkdir(parents=True, exist_ok=True) |
| return { |
| "analysis_directory": run_dir, |
| "outdir": outdir, |
| "filename": "multiqc_report.html", |
| "force": True, |
| } |
|
|
| |
| return { |
| "analysis_directory": run_dir, |
| "outdir": run_dir / f"{step_name}_output", |
| } |
|
|
| @staticmethod |
| def _extract_code_repo(task: str, input_manifest: dict[str, Any]) -> str: |
| manifest_repo = str(input_manifest.get("code_repo") or "").strip() |
| if manifest_repo: |
| return manifest_repo |
| m = re.search(r"Code repo:\s*(https?://\S+)", task, re.I) |
| if m: |
| return m.group(1).rstrip(").,;") |
| return "" |
|
|
| def _run_local_command(self, cmd: list[str], log_file: Path, cwd: Path | None = None, timeout_s: int = 900) -> dict[str, Any]: |
| try: |
| self._append_log(log_file, f"[local-cmd] {' '.join(cmd)} cwd={cwd or self.project_root}") |
| completed = subprocess.run( |
| cmd, |
| cwd=str(cwd) if cwd else None, |
| capture_output=True, |
| text=True, |
| timeout=timeout_s, |
| ) |
| return { |
| "command_executed": " ".join(cmd), |
| "return_code": completed.returncode, |
| "stdout": completed.stdout or "", |
| "stderr": completed.stderr or "", |
| } |
| except Exception as exc: |
| tb = traceback.format_exc() |
| return { |
| "command_executed": " ".join(cmd), |
| "return_code": -1, |
| "error": str(exc), |
| "traceback": tb, |
| } |
|
|
| @staticmethod |
| def _extract_readme_run_commands(source_dir: Path, max_commands: int = 6) -> list[list[str]]: |
| candidates: list[list[str]] = [] |
| readme_files = [ |
| source_dir / "README.md", |
| source_dir / "readme.md", |
| source_dir / "README.rst", |
| ] |
| text = "" |
| for fp in readme_files: |
| if fp.exists(): |
| try: |
| text = fp.read_text(encoding="utf-8", errors="replace") |
| break |
| except Exception: |
| continue |
| if not text: |
| return candidates |
|
|
| |
| blocks = re.findall(r"```(?:bash|sh|shell)?\n([\s\S]*?)```", text, re.I) |
| lines: list[str] = [] |
| for b in blocks: |
| lines.extend(b.splitlines()) |
| if not lines: |
| lines = text.splitlines() |
|
|
| allowed_prefix = ( |
| "python ", |
| "python3 ", |
| "bash ", |
| "sh ", |
| "./", |
| "Rscript ", |
| "R -e ", |
| "R --vanilla ", |
| "R ", |
| "snakemake", |
| "nextflow run", |
| ) |
| forbidden = ("sudo ", "rm -rf", "docker system", "shutdown", "reboot") |
| for raw in lines: |
| line = raw.strip() |
| if line.startswith("$ "): |
| line = line[2:].strip() |
| if not line or line.startswith("#"): |
| continue |
| if any(bad in line for bad in forbidden): |
| continue |
| if line.startswith(allowed_prefix): |
| try: |
| cmd = shlex.split(line) |
| except Exception: |
| continue |
| if cmd: |
| candidates.append(cmd) |
| if len(candidates) >= max_commands: |
| break |
| return candidates |
|
|
| @staticmethod |
| def _is_safe_run_command(cmd: list[str]) -> bool: |
| if not cmd: |
| return False |
| joined = " ".join(cmd).lower() |
| forbidden = [ |
| "rm -rf", |
| "sudo ", |
| "shutdown", |
| "reboot", |
| ":(){:|:&};:", |
| "mkfs", |
| "dd if=", |
| "curl ", |
| "wget ", |
| "scp ", |
| "ssh ", |
| "docker system", |
| ] |
| if any(x in joined for x in forbidden): |
| return False |
| allowed_heads = {"python", "python3", "bash", "sh", "rscript", "r", "snakemake", "nextflow", "make"} |
| head = cmd[0].lower() |
| return head in allowed_heads or head.startswith("./") |
|
|
| def _plan_source_commands_with_gemini( |
| self, |
| *, |
| task: str, |
| source_dir: Path, |
| input_manifest: dict[str, Any], |
| log_file: Path, |
| max_commands: int = 5, |
| ) -> list[list[str]]: |
| api_key = os.getenv("GEMINI_API_KEY", "").strip() |
| print("api_key:"+api_key) |
| if not api_key: |
| self._append_log(log_file, "[gemini] planner skipped: GEMINI_API_KEY missing") |
| return [] |
|
|
| readme_text = "" |
| for fp in [source_dir / "README.md", source_dir / "readme.md", source_dir / "README.rst"]: |
| if fp.exists(): |
| readme_text = fp.read_text(encoding="utf-8", errors="replace") |
| break |
| if len(readme_text) > 12000: |
| readme_text = readme_text[:12000] |
| print("readme_text:"+readme_text) |
| endpoint = ( |
| "https://generativelanguage.googleapis.com/v1beta/models/" |
| f"gemini-2.5-flash-lite:generateContent?key={api_key}" |
| ) |
| planner_prompt = ( |
| "You are a bioinformatics reproducibility execution planner.\n" |
| "Given repository README and task context, propose up to 5 LOCAL runnable commands.\n" |
| "Output JSON with key `commands`, where each command is either:\n" |
| '1) ["python","script.py","--arg"] or 2) "python script.py --arg"\n' |
| "Rules:\n" |
| "- Only local run commands: python/python3/bash/sh/Rscript/R/snakemake/nextflow/make\n" |
| "- No network download commands (curl/wget/git clone), no sudo, no destructive commands.\n" |
| "- Prefer commands that execute original method in the repository.\n" |
| "- Return JSON only.\n" |
| ) |
| payload = { |
| "contents": [ |
| { |
| "role": "user", |
| "parts": [ |
| { |
| "text": planner_prompt |
| + "\n" |
| + json.dumps( |
| { |
| "task": task, |
| "repo_path": str(source_dir), |
| "data_type": input_manifest.get("data_type", ""), |
| "repro_steps": input_manifest.get("repro_steps", []), |
| "readme_excerpt": readme_text, |
| }, |
| ensure_ascii=True, |
| ) |
| } |
| ], |
| } |
| ], |
| "generationConfig": {"temperature": 0.1}, |
| } |
| print("payload:"+json.dumps(payload, ensure_ascii=False)) |
| try: |
| resp = requests.post(endpoint, json=payload, timeout=60) |
| if resp.status_code >= 400: |
| self._append_log( |
| log_file, |
| f"[gemini] planner failed status={resp.status_code} body={(resp.text or '')[:500]}", |
| ) |
| return self._extract_readme_run_commands(source_dir, max_commands=max_commands) |
| data = resp.json() |
| text = "" |
| for part in (data.get("candidates", [{}])[0].get("content", {}).get("parts", []) or []): |
| if "text" in part: |
| text += part["text"] |
| obj = {} |
| try: |
| obj = json.loads(text) if text.strip() else {} |
| except Exception: |
| m = re.search(r"```json\s*([\s\S]*?)```", text, re.I) |
| if m: |
| obj = json.loads(m.group(1)) |
| else: |
| m = re.search(r"(\{[\s\S]*\})", text) |
| if m: |
| obj = json.loads(m.group(1)) |
| commands = obj.get("commands", []) |
| |
| print("obj:"+json.dumps(obj, ensure_ascii=False)) |
| if not commands and isinstance(obj, list): |
| commands = obj |
| |
| if not commands and text.strip(): |
| candidate_lines = [ln.strip() for ln in text.splitlines() if ln.strip()] |
| commands = [ |
| ln.lstrip("- ").strip() |
| for ln in candidate_lines |
| if ln.lower().startswith(("python ", "python3 ", "bash ", "sh ", "rscript ", "r ", "snakemake", "nextflow", "make ")) |
| ] |
| safe_cmds: list[list[str]] = [] |
| for item in commands[:max_commands]: |
| if isinstance(item, str): |
| cmd = shlex.split(item) |
| elif isinstance(item, list): |
| cmd = [str(x) for x in item] |
| else: |
| continue |
| if self._is_safe_run_command(cmd): |
| safe_cmds.append(cmd) |
| if not safe_cmds: |
| self._append_log(log_file, f"[gemini] empty command plan; raw={(text or '')[:500]}") |
| return self._extract_readme_run_commands(source_dir, max_commands=max_commands) |
| self._append_log(log_file, f"[gemini] planned_commands={json.dumps(safe_cmds, ensure_ascii=True)}") |
| return safe_cmds |
| except Exception as exc: |
| self._append_log(log_file, f"[gemini] planner exception: {exc}") |
| return self._extract_readme_run_commands(source_dir, max_commands=max_commands) |
|
|
| def _execute_local_step( |
| self, |
| step_name: str, |
| *, |
| task: str, |
| input_manifest: dict[str, Any], |
| context: dict[str, Any], |
| log_file: Path, |
| ) -> dict[str, Any]: |
| run_dir: Path = context["run_dir"] |
| artifacts: dict[str, Any] = context["artifacts"] |
| code_repo = self._extract_code_repo(task=task, input_manifest=input_manifest) |
| source_dir = run_dir / "06_source_code" |
| artifacts["source_repo"] = code_repo |
| artifacts["source_dir"] = source_dir |
|
|
| if step_name == "source_clone": |
| if not code_repo or "unknown_repo" in code_repo: |
| return {"ok": False, "skipped": True, "result": {"warning": "source_repo_missing"}} |
| source_dir.parent.mkdir(parents=True, exist_ok=True) |
| if source_dir.exists(): |
| result = self._run_local_command(["git", "-C", str(source_dir), "pull"], log_file=log_file) |
| else: |
| result = self._run_local_command(["git", "clone", "--depth", "1", code_repo, str(source_dir)], log_file=log_file) |
| ok = result.get("return_code", 1) == 0 |
| return {"ok": ok, "result": result} |
|
|
| if step_name == "source_execute": |
| if not source_dir.exists(): |
| return {"ok": False, "skipped": True, "result": {"warning": "source_dir_not_ready"}} |
| attempts: list[dict[str, Any]] = [] |
| run_attempt_success = False |
| has_r = shutil.which("R") is not None |
| |
| req = source_dir / "requirements.txt" |
| if req.exists(): |
| setup = self._run_local_command(["python", "-m", "pip", "install", "-r", str(req)], cwd=source_dir, log_file=log_file) |
| setup["attempt_type"] = "setup" |
| attempts.append(setup) |
| r_renv = source_dir / "renv.lock" |
| r_desc = source_dir / "DESCRIPTION" |
| if r_renv.exists(): |
| if has_r: |
| setup = self._run_local_command( |
| [ |
| "R", |
| "--vanilla", |
| "-e", |
| "if (!requireNamespace('renv', quietly=TRUE)) install.packages('renv', repos='https://cloud.r-project.org'); renv::restore(prompt=FALSE)", |
| ], |
| cwd=source_dir, |
| log_file=log_file, |
| timeout_s=1800, |
| ) |
| setup["attempt_type"] = "setup" |
| attempts.append(setup) |
| else: |
| attempts.append( |
| { |
| "attempt_type": "setup", |
| "return_code": -1, |
| "warning": "R_not_found_for_renv_restore", |
| "command_executed": "R --vanilla -e <renv::restore>", |
| } |
| ) |
| elif r_desc.exists() and has_r: |
| setup = self._run_local_command( |
| [ |
| "R", |
| "--vanilla", |
| "-e", |
| "if (!requireNamespace('remotes', quietly=TRUE)) install.packages('remotes', repos='https://cloud.r-project.org'); remotes::install_local('.', upgrade='never')", |
| ], |
| cwd=source_dir, |
| log_file=log_file, |
| timeout_s=1800, |
| ) |
| setup["attempt_type"] = "setup" |
| attempts.append(setup) |
| |
| candidate_scripts = ["reproduce.sh", "run.sh", "scripts/reproduce.sh"] |
| executed = False |
| for rel in candidate_scripts: |
| fp = source_dir / rel |
| if fp.exists(): |
| run = self._run_local_command(["bash", str(fp)], cwd=source_dir, log_file=log_file) |
| run["attempt_type"] = "run" |
| attempts.append(run) |
| run_attempt_success = run_attempt_success or (run.get("return_code", 1) == 0) |
| executed = True |
| break |
| |
| gemini_cmds: list[list[str]] = [] |
| if not executed: |
| gemini_cmds = self._plan_source_commands_with_gemini( |
| task=task, |
| source_dir=source_dir, |
| input_manifest=input_manifest, |
| log_file=log_file, |
| ) |
| print(gemini_cmds) |
| for cmd in gemini_cmds: |
| run = self._run_local_command(cmd, cwd=source_dir, log_file=log_file) |
| run["attempt_type"] = "run_gemini" |
| attempts.append(run) |
| if run.get("return_code", 1) == 0: |
| run_attempt_success = True |
| executed = True |
| break |
|
|
| if run_attempt_success: |
| return {"ok": True, "result": {"attempts": attempts, "gemini_planned_commands": gemini_cmds}} |
| warning = "source_execution_not_reproduced" |
| if not gemini_cmds and not executed: |
| warning = "gemini_no_executable_command" |
| return { |
| "ok": False, |
| "skipped": True, |
| "result": { |
| "warning": warning, |
| "gemini_planned_commands": gemini_cmds, |
| "attempts": attempts, |
| }, |
| } |
|
|
| if step_name == "summarize_repro": |
| summary = { |
| "source_repo": code_repo or "unknown_repo", |
| "data_sources": input_manifest.get("data_sources", []), |
| "repro_steps": input_manifest.get("repro_steps", []), |
| "notes": ( |
| "Summary generated from benchmark prompt execution. " |
| "Review source execution attempts and MCP preprocessing outputs for final conclusion." |
| ), |
| } |
| return {"ok": True, "result": summary} |
|
|
| return {"ok": False, "result": {"error": f"unsupported_local_step:{step_name}"}} |
|
|
| @staticmethod |
| def _bowtie2_index_exists(index_base: str | Path) -> bool: |
| base = Path(index_base) |
| bt2_suffixes = [".1.bt2", ".2.bt2", ".3.bt2", ".4.bt2", ".rev.1.bt2", ".rev.2.bt2"] |
| bt2l_suffixes = [s.replace(".bt2", ".bt2l") for s in bt2_suffixes] |
| return any((base.parent / (base.name + s)).exists() for s in bt2_suffixes) or any( |
| (base.parent / (base.name + s)).exists() for s in bt2l_suffixes |
| ) |
|
|
| def execute_task( |
| self, |
| task: str, |
| input_manifest: dict[str, Any], |
| task_scope: str, |
| pipeline_config: dict[str, Any] | None = None, |
| ) -> dict[str, Any]: |
| report = ExperimentReport.new( |
| task=task, |
| input_manifest=input_manifest, |
| pipeline_config_id=(pipeline_config or {}).get("config_id"), |
| ) |
| run_dir = self.default_results_root / report.run_id |
| run_dir.mkdir(parents=True, exist_ok=True) |
| log_path = run_dir / "pipeline.log" |
| log_path.write_text("", encoding="utf-8") |
|
|
| plan = self._plan_task(task=task, task_scope=task_scope, pipeline_config=pipeline_config) |
| route_snapshot: dict[str, Any] = {} |
| self._append_log(log_path, f"[planner] generated_plan={json.dumps(plan, ensure_ascii=True)}") |
|
|
| try: |
| params = (pipeline_config or {}).get("parameters", {}) |
| threads = int(params.get("threads", 4)) |
| quality_cutoff = int(params.get("quality_cutoff", 20)) |
| index_base = input_manifest.get("reference_index_base") or params.get("reference_index_base") |
| r1 = Path(input_manifest["r1"]).resolve() |
| r2 = Path(input_manifest["r2"]).resolve() |
|
|
| if task_scope != "first_pipeline": |
| self._append_log( |
| log_path, |
| "[planner] non-first_pipeline scope detected; running generic configured steps plan", |
| ) |
| if not r1.exists() or not r2.exists(): |
| raise FileNotFoundError("Input FASTQ files not found for r1/r2.") |
|
|
| index_ready = bool(index_base) and self._bowtie2_index_exists(index_base) |
| if not index_ready: |
| before = len(plan) |
| plan = [s for s in plan if s.get("name") != "align"] |
| self._append_log( |
| log_path, |
| "[planner] align_step_skipped reason=missing_or_invalid_reference_index", |
| ) |
| self._append_log(log_path, f"[planner] plan_rewritten from_steps={before} to_steps={len(plan)}") |
|
|
| step_results: dict[str, Any] = {} |
| artifacts: dict[str, Any] = {} |
| warnings: list[str] = [] |
| skippable_steps = {"aggregate"} |
| context = { |
| "run_dir": run_dir, |
| "r1": r1, |
| "r2": r2, |
| "threads": threads, |
| "quality_cutoff": quality_cutoff, |
| "index_base": index_base, |
| "artifacts": artifacts, |
| } |
|
|
| for step in plan: |
| step_name = step["name"] |
| if step.get("local_executor"): |
| local_result = self._execute_local_step( |
| step_name=step_name, |
| task=task, |
| input_manifest=input_manifest, |
| context=context, |
| log_file=log_path, |
| ) |
| step_results[step_name] = local_result |
| if not local_result.get("ok"): |
| warn = f"skip_or_fail_local_step:{step_name}" |
| warnings.append(warn) |
| self._append_log(log_path, f"[planner] {warn}") |
| continue |
| candidates = step["candidates"] |
| chosen_route = None |
| for candidate in candidates: |
| chosen_route = self._route_or_generate(candidate, log_file=log_path) |
| if chosen_route is not None: |
| if self.router.binary_available(chosen_route.function_name): |
| break |
| self._append_log( |
| log_path, |
| f"[planner] skip_candidate={candidate} reason=binary_unavailable binary={chosen_route.binary_name}", |
| ) |
| chosen_route = None |
| if chosen_route is None: |
| if step_name in skippable_steps: |
| warn = f"skip_step:{step_name} reason=no_available_mcp_route" |
| warnings.append(warn) |
| self._append_log(log_path, f"[planner] {warn}") |
| step_results[step_name] = { |
| "ok": False, |
| "skipped": True, |
| "result": {"warning": warn}, |
| } |
| continue |
| raise RuntimeError(f"no_mcp_route_found_for_step:{step_name}") |
|
|
| route_snapshot[step_name] = { |
| "requested_candidates": candidates, |
| "selected_tool": chosen_route.tool_name, |
| "selected_function": chosen_route.function_name, |
| "binary": chosen_route.binary_name, |
| "binary_available": self.router.binary_available(chosen_route.function_name), |
| "server_script": str(chosen_route.server_script), |
| "server_exists": chosen_route.server_script.exists(), |
| } |
|
|
| kwargs = self._build_step_kwargs( |
| step_name=step_name, |
| route_function=chosen_route.function_name, |
| context=context, |
| ) |
| result = self._invoke_mcp_tool(chosen_route, kwargs=kwargs, log_file=log_path) |
| step_results[step_name] = result |
| if not result["ok"]: |
| if step_name in skippable_steps: |
| warn = f"skip_step:{step_name} reason=tool_execution_failed" |
| warnings.append(warn) |
| self._append_log(log_path, f"[planner] {warn}") |
| step_results[step_name] = { |
| "ok": False, |
| "skipped": True, |
| "result": { |
| **self._jsonable(result["result"]), |
| "warning": warn, |
| }, |
| } |
| continue |
| raise RuntimeError(f"{step_name}_failed") |
|
|
| |
| output_files = result["result"].get("output_files") or [] |
| if step_name == "trim": |
| trim_dir = run_dir / "02_trim" |
| |
| if output_files: |
| r1_candidates = [Path(p) for p in output_files if ("_val_1" in p or "_fastp_R1" in p)] |
| r2_candidates = [Path(p) for p in output_files if ("_val_2" in p or "_fastp_R2" in p)] |
| if r1_candidates and r2_candidates: |
| artifacts["trimmed_r1"] = r1_candidates[-1] |
| artifacts["trimmed_r2"] = r2_candidates[-1] |
|
|
| if "trimmed_r1" not in artifacts or "trimmed_r2" not in artifacts: |
| trimmed_r1, trimmed_r2 = self._pick_trimmed_pair(trim_dir) |
| if trimmed_r1 and trimmed_r2: |
| artifacts["trimmed_r1"] = trimmed_r1 |
| artifacts["trimmed_r2"] = trimmed_r2 |
| if "trimmed_r1" not in artifacts or "trimmed_r2" not in artifacts: |
| raise RuntimeError("trimmed_files_not_found") |
|
|
| multiqc_report = run_dir / "05_multiqc" / "multiqc_report.html" |
| if (step_results.get("aggregate") or {}).get("ok") and multiqc_report.exists(): |
| artifacts["multiqc_report"] = multiqc_report |
|
|
| self._collect_fastqc_artifacts(run_dir=run_dir, artifacts=artifacts) |
|
|
| summary = { |
| "status": "completed_with_warnings" if warnings else "completed", |
| "task_scope": task_scope, |
| "task": task, |
| "plan": self._jsonable(plan), |
| "steps": self._jsonable({k: {"ok": v["ok"], "result": v["result"]} for k, v in step_results.items()}), |
| "warnings": warnings, |
| "artifacts": { |
| "trimmed_r1": str(artifacts.get("trimmed_r1", "")), |
| "trimmed_r2": str(artifacts.get("trimmed_r2", "")), |
| "sam": str(artifacts.get("sam", "")), |
| "multiqc_report": str(artifacts.get("multiqc_report", multiqc_report)), |
| "fastqc_raw_html": self._jsonable(artifacts.get("fastqc_raw_html", [])), |
| "fastqc_trimmed_html": self._jsonable(artifacts.get("fastqc_trimmed_html", [])), |
| }, |
| "router": self._jsonable(route_snapshot), |
| } |
| (run_dir / "summary.json").write_text( |
| json.dumps(summary, indent=2, ensure_ascii=True), |
| encoding="utf-8", |
| ) |
|
|
| analysis_report_path = self._write_analysis_report( |
| run_dir=run_dir, |
| task=task, |
| task_scope=task_scope, |
| status=summary["status"], |
| step_results=step_results, |
| warnings=warnings, |
| artifacts=artifacts, |
| ) |
|
|
| report.status = "completed_with_warnings" if warnings else "completed" |
| report.execution_log = str(log_path) |
| report.output_artifacts = [str(run_dir / "summary.json"), str(analysis_report_path)] |
| if artifacts.get("multiqc_report"): |
| report.output_artifacts.append(str(artifacts["multiqc_report"])) |
| report.metrics = { |
| "steps": len(plan), |
| "has_pipeline_config": bool(pipeline_config), |
| "task_scope": task_scope, |
| "plan": self._jsonable(plan), |
| "router": self._jsonable(route_snapshot), |
| "step_results": self._jsonable(step_results), |
| "warnings": warnings, |
| "log_tail": self._read_log_tail(log_path), |
| } |
| if warnings: |
| report.notes = ( |
| "Executed with warnings via MCP routing plan. " |
| f"Warnings: {'; '.join(warnings)}" |
| ) |
| else: |
| report.notes = "Executed via MCP routing plan with dynamic tool selection and converter fallback." |
| except Exception as exc: |
| report.status = "failed" |
| report.execution_log = str(log_path) |
| report.failures.append( |
| FailureMode( |
| step_name="executor", |
| error_type=type(exc).__name__, |
| error_message=str(exc), |
| hint="Inspect pipeline.log and parameters.", |
| ) |
| ) |
| self._append_log(log_path, f"[V1] status=failed error={exc}") |
| failure_summary = { |
| "status": "failed", |
| "task_scope": task_scope, |
| "task": task, |
| "plan": self._jsonable(plan), |
| "router": self._jsonable(route_snapshot), |
| "partial_steps": self._jsonable(step_results), |
| "artifacts": self._jsonable(artifacts), |
| "error": {"type": type(exc).__name__, "message": str(exc)}, |
| "log_tail": self._read_log_tail(log_path), |
| } |
| (run_dir / "summary.json").write_text( |
| json.dumps(failure_summary, indent=2, ensure_ascii=True), |
| encoding="utf-8", |
| ) |
| report.output_artifacts = [str(run_dir / "summary.json")] |
| report.metrics = { |
| "task_scope": task_scope, |
| "router": self._jsonable(route_snapshot), |
| "plan": self._jsonable(plan), |
| "partial_steps": self._jsonable(step_results), |
| "log_tail": self._read_log_tail(log_path), |
| } |
|
|
| self.memory.save_report(report) |
| return report.to_dict() |
|
|
| def execute_autopilot( |
| self, |
| user_goal: str, |
| data_dir: str | Path, |
| task_scope: str = "first_pipeline", |
| pipeline_config: dict[str, Any] | None = None, |
| manifest_overrides: dict[str, Any] | None = None, |
| ) -> dict[str, Any]: |
| r1, r2 = self._discover_paired_fastq(Path(data_dir)) |
| manifest: dict[str, Any] = {"r1": str(r1), "r2": str(r2)} |
| if manifest_overrides: |
| manifest.update(manifest_overrides) |
| |
| if pipeline_config and pipeline_config.get("parameters", {}).get("reference_index_base"): |
| manifest["reference_index_base"] = pipeline_config["parameters"]["reference_index_base"] |
| return self.execute_task( |
| task=user_goal, |
| input_manifest=manifest, |
| task_scope=task_scope, |
| pipeline_config=pipeline_config, |
| ) |
|
|
| @staticmethod |
| def _discover_paired_fastq(data_dir: Path) -> tuple[Path, Path]: |
| if not data_dir.exists(): |
| raise FileNotFoundError(f"data_dir not found: {data_dir}") |
|
|
| files = sorted( |
| [ |
| p for p in data_dir.iterdir() |
| if p.is_file() and p.name.lower().endswith((".fastq", ".fq", ".fastq.gz", ".fq.gz")) |
| ] |
| ) |
| if len(files) < 2: |
| raise ValueError("Need at least two FASTQ files in data_dir.") |
|
|
| |
| for f in files: |
| name = f.name |
| r2_name = ( |
| name.replace("_R1", "_R2") |
| .replace(".R1.", ".R2.") |
| .replace("_1.", "_2.") |
| ) |
| for g in files: |
| if g.name == r2_name: |
| return f, g |
|
|
| |
| return files[0], files[1] |
|
|
| @staticmethod |
| def _collect_fastqc_artifacts(run_dir: Path, artifacts: dict[str, Any]) -> None: |
| raw_dir = run_dir / "01_fastqc_raw" |
| trim_dir = run_dir / "04_fastqc_trimmed" |
| if raw_dir.exists(): |
| artifacts["fastqc_raw_html"] = [str(p) for p in sorted(raw_dir.glob("*_fastqc.html"))] |
| if trim_dir.exists(): |
| artifacts["fastqc_trimmed_html"] = [str(p) for p in sorted(trim_dir.glob("*_fastqc.html"))] |
|
|
| @staticmethod |
| def _extract_fastp_metrics(step_results: dict[str, Any]) -> dict[str, Any]: |
| trim = step_results.get("trim", {}) |
| result = trim.get("result", {}) if isinstance(trim, dict) else {} |
| output_files = result.get("output_files") or [] |
| json_fp = None |
| for fp in output_files: |
| if str(fp).endswith(".json") and "fastp" in str(fp): |
| json_fp = Path(fp) |
| break |
| if not json_fp or not json_fp.exists(): |
| return {} |
| try: |
| payload = json.loads(json_fp.read_text(encoding="utf-8")) |
| summary = payload.get("summary", {}) |
| before = summary.get("before_filtering", {}) |
| after = summary.get("after_filtering", {}) |
| filtering = summary.get("filtering_result", {}) |
| return { |
| "before_total_reads": before.get("total_reads"), |
| "after_total_reads": after.get("total_reads"), |
| "q30_rate_after": after.get("q30_rate"), |
| "passed_filter_reads": filtering.get("passed_filter_reads"), |
| "low_quality_reads": filtering.get("low_quality_reads"), |
| } |
| except Exception: |
| return {} |
|
|
| def _write_analysis_report( |
| self, |
| run_dir: Path, |
| task: str, |
| task_scope: str, |
| status: str, |
| step_results: dict[str, Any], |
| warnings: list[str], |
| artifacts: dict[str, Any], |
| ) -> Path: |
| metrics = self._extract_fastp_metrics(step_results) |
| lines: list[str] = [] |
| lines.append("# BioClawMCP Analysis Report") |
| lines.append("") |
| lines.append(f"- Task: {task}") |
| lines.append(f"- Task Scope: {task_scope}") |
| lines.append(f"- Status: {status}") |
| lines.append("") |
| if warnings: |
| lines.append("## Warnings") |
| for w in warnings: |
| lines.append(f"- {w}") |
| lines.append("") |
|
|
| lines.append("## Pipeline Execution Summary") |
| lines.append("| Step | Status |") |
| lines.append("|---|---|") |
| for step_name, step_payload in step_results.items(): |
| ok = step_payload.get("ok", False) |
| skipped = step_payload.get("skipped", False) |
| status_text = "skipped" if skipped else ("ok" if ok else "failed") |
| lines.append(f"| {step_name} | {status_text} |") |
| lines.append("") |
|
|
| if metrics: |
| lines.append("## fastp Key Metrics") |
| lines.append(f"- Before total reads: {metrics.get('before_total_reads')}") |
| lines.append(f"- After total reads: {metrics.get('after_total_reads')}") |
| lines.append(f"- Q30 rate after: {metrics.get('q30_rate_after')}") |
| lines.append(f"- Passed filter reads: {metrics.get('passed_filter_reads')}") |
| lines.append(f"- Low quality reads: {metrics.get('low_quality_reads')}") |
| lines.append("") |
|
|
| lines.append("## Output Artifacts") |
| if artifacts.get("trimmed_r1"): |
| lines.append(f"- Trimmed R1: {artifacts.get('trimmed_r1')}") |
| if artifacts.get("trimmed_r2"): |
| lines.append(f"- Trimmed R2: {artifacts.get('trimmed_r2')}") |
| if artifacts.get("sam"): |
| lines.append(f"- Alignment SAM: {artifacts.get('sam')}") |
| if artifacts.get("multiqc_report"): |
| lines.append(f"- MultiQC report: {artifacts.get('multiqc_report')}") |
| for fp in artifacts.get("fastqc_raw_html", []): |
| lines.append(f"- FastQC raw HTML: {fp}") |
| for fp in artifacts.get("fastqc_trimmed_html", []): |
| lines.append(f"- FastQC trimmed HTML: {fp}") |
| lines.append("") |
|
|
| lines.append("## Per-step Command & Logs") |
| for step_name, step_payload in step_results.items(): |
| lines.append(f"### {step_name}") |
| result = step_payload.get("result", {}) if isinstance(step_payload, dict) else {} |
| cmd = result.get("command_executed", "") |
| if cmd: |
| lines.append("```bash") |
| lines.append(cmd) |
| lines.append("```") |
| stderr = result.get("stderr", "") |
| stdout = result.get("stdout", "") |
| if stderr: |
| lines.append("stderr:") |
| lines.append("```text") |
| lines.append(str(stderr)[:6000]) |
| lines.append("```") |
| if stdout: |
| lines.append("stdout:") |
| lines.append("```text") |
| lines.append(str(stdout)[:6000]) |
| lines.append("```") |
| lines.append("") |
|
|
| report_path = run_dir / "analysis_report.md" |
| report_path.write_text("\n".join(lines), encoding="utf-8") |
| return report_path |
|
|