From f21bf345a63ca13aa0d4b9b75326495863fb0fa4 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Thu, 9 Jul 2026 12:20:25 -0400 Subject: [PATCH] =?UTF-8?q?feat(runner):=20=E6=B3=A8=E5=85=A5=20tool=5Fdis?= =?UTF-8?q?patch=5Ffactory/prompt=5Fbuilder=5Ffactory=20+=20fail-fast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runner.__init__ 新增 2 个可选参数: - tool_dispatch_factory: 工具调度工厂 - prompt_builder_factory: prompt 构建工厂 infer/eval/train 模式缺少工厂时 fail-fast 抛 ValueError。 _make_tool_dispatch_fn/_make_prompt_builder 优先使用注入工厂。 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/harness/runner.py | 34 ++++++++++-- tests/unit/test_harness_runner.py | 92 +++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 6 deletions(-) diff --git a/app/harness/runner.py b/app/harness/runner.py index 2cc4e28..6e1752d 100644 --- a/app/harness/runner.py +++ b/app/harness/runner.py @@ -449,6 +449,8 @@ class Runner: evolve_llm: 进化用 LLMProvider(thinking=True)。 vlm: VLMProvider。 telemetry: 遥测记录端口。 + tool_dispatch_factory: 工具调度工厂(infer/eval/train 模式必传)。 + prompt_builder_factory: prompt 构建工厂(infer/eval/train 模式必传)。 """ def __init__( @@ -459,12 +461,28 @@ class Runner: evolve_llm: LLMProvider, vlm: VLMProvider, telemetry: TelemetryRecorder, + tool_dispatch_factory: Any | None = None, + prompt_builder_factory: Any | None = None, ) -> None: self._config = config self._llm = llm self._evolve_llm = evolve_llm self._vlm = vlm self._telemetry = telemetry + self._tool_dispatch_factory = tool_dispatch_factory + self._prompt_builder_factory = prompt_builder_factory + + # fail-fast: 需要推理的模式必须注入工厂 + if config.mode in {"infer", "eval", "train"} and ( + tool_dispatch_factory is None or prompt_builder_factory is None + ): + raise ValueError( + f"mode={config.mode!r} 需要 tool_dispatch_factory 和 " + f"prompt_builder_factory,但收到 " + f"tool_dispatch_factory={tool_dispatch_factory!r}, " + f"prompt_builder_factory={prompt_builder_factory!r}" + ) + self._ensure_workspace() self._paths: ResolvedPaths = resolve_paths(config.workspace_dir) @@ -2062,22 +2080,26 @@ class Runner: # ----------------------------------------------------------------------- def _make_tool_dispatch_fn(self, *, skills_dir: Path | None = None): - """构造工具调度函数(由子类或 main.py 覆盖)。""" + """构造工具调度函数(优先使用注入的工厂,否则 noop 降级)。""" + if self._tool_dispatch_factory is not None: + return self._tool_dispatch_factory(skills_dir=skills_dir) + # noop fallback:diagnose/promote 等不需要推理的模式 async def _noop_dispatch(tool_name: str, args: dict, *, context: dict) -> str: - raise NotImplementedError( - f"工具 {tool_name} 调度未配置(需由 main.py 注入 tool_dispatch_fn)" - ) + raise NotImplementedError(f"工具 {tool_name} 调度未配置") return _noop_dispatch def _make_prompt_builder( self, *, skills_dir: Path | None = None, prompts_dir: Path | None = None ): - """构造 prompt 构建函数(由子类或 main.py 覆盖)。""" + """构造 prompt 构建函数(优先使用注入的工厂,否则 noop 降级)。""" + if self._prompt_builder_factory is not None: + return self._prompt_builder_factory(skills_dir=skills_dir, prompts_dir=prompts_dir) + # noop fallback:diagnose/promote 等不需要推理的模式 def _noop_builder(qa: GeneratedQuestion) -> tuple[str, str]: - raise NotImplementedError("prompt_builder 未配置(需由 main.py 注入)") + raise NotImplementedError("prompt_builder 未配置") return _noop_builder diff --git a/tests/unit/test_harness_runner.py b/tests/unit/test_harness_runner.py index 76e0211..594f38f 100644 --- a/tests/unit/test_harness_runner.py +++ b/tests/unit/test_harness_runner.py @@ -14,6 +14,7 @@ from unittest.mock import MagicMock, patch import pytest from app.harness.runner import ( + Runner, _apply_batch_correctness, _batch_from_ids, _build_comparison_pairs, @@ -680,3 +681,94 @@ class TestCooldownDecrement: cooldown = {t: n - 1 for t, n in cooldown.items() if n - 1 > 0} assert cooldown == {} + + +# ========================================================================= +# factory 注入(Task 4) +# ========================================================================= + + +class TestRunnerFactoryInjection: + """Runner 构造时 tool_dispatch_factory / prompt_builder_factory 注入检查。""" + + @staticmethod + def _base_config(tmp_path: Path, *, mode: str = "infer", **overrides): + """构造 RunConfig,所有必填字段都给默认值。""" + from app.harness.config import RunConfig + + defaults = { + "workspace_dir": tmp_path, + "store_dir": tmp_path, + "mode": mode, + "concurrency": 1, + "max_steps": 5, + "skill_mode": "none", + "n_samples": 0, + "questions": "benchmarks/Video-MME", + "skills_version": "v1", + "prompts_version": "v1", + "epochs": 1, + "diag_size": 10, + "diag_correct_ratio": 0.5, + "val_size": 24, + "val_correct_ratio": 0.5, + "edit_budget_start": 5, + "edit_budget_end": 2, + "batch_size": 5, + "min_class_per_batch": 2, + "eval_min_per_class": 2, + "early_stop_patience": 3, + "test_size": 10, + "use_slow_momentum": False, + "gate_e_confirm": 20.0, + "gate_e_provisional": 3.0, + "gate_w_net_min": 2, + "gate_delta_min": 0.02, + "gate_lambda_dir": -0.642, + "gate_e_rollback": 10.0, + "gate_block": 8, + "gate_n_max": 40, + "gate_p_low": 0.05, + "gate_p_high": 0.95, + "gate_probe_quota": 0.2, + "gate_gamma_decay": 0.9, + "gate_cooldown_steps": 2, + "gate_guard_err": 0.10, + "skill_update_mode": "patch", + "appendix_consolidate_threshold": 6, + } + defaults.update(overrides) + return RunConfig(**defaults) + + def test_infer_mode_missing_factory_raises(self, tmp_path: Path) -> None: + """infer 模式缺少工厂时抛出 ValueError。""" + config = self._base_config(tmp_path, mode="infer") + with pytest.raises(ValueError, match="tool_dispatch_factory"): + Runner( + config, + llm=MagicMock(), + evolve_llm=MagicMock(), + vlm=MagicMock(), + telemetry=MagicMock(), + ) + + def test_diagnose_mode_allows_none_factory(self, tmp_path: Path) -> None: + """diagnose 模式不需要工厂,允许 None。""" + ws = tmp_path / "ws" + ws.mkdir() + (ws / "manifest.json").write_text( + '{"name":"ws","created_at":"","store":"../store",' + '"current":{"videos":"v","questions":"q","skills":"s","prompts":"p"},' + '"history":[]}' + ) + config = self._base_config(tmp_path, mode="diagnose", workspace_dir=ws, run_id="test_run") + # 不应抛出 ValueError + runner = Runner( + config, + llm=MagicMock(), + evolve_llm=MagicMock(), + vlm=MagicMock(), + telemetry=MagicMock(), + ) + assert runner._tool_dispatch_factory is None + assert runner._prompt_builder_factory is None