feat(runner): 注入 tool_dispatch_factory/prompt_builder_factory + fail-fast

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 12:20:25 -04:00
parent 924160c779
commit f21bf345a6
2 changed files with 120 additions and 6 deletions
+92
View File
@@ -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