From 1061e3689364697124473be16e2326c8666afd5b Mon Sep 17 00:00:00 2001 From: Kavya Sree Kaitepalli Date: Mon, 13 Jul 2026 08:40:32 +0000 Subject: [PATCH 1/2] feat: enhance runner configuration and loading mechanism for auto_memory module --- src/microbots/auto_memory/cli.py | 92 +++++++++++++++++- src/microbots/auto_memory/config.py | 30 ++++++ src/microbots/auto_memory/orchestrator.py | 6 +- src/microbots/auto_memory/runners/base.py | 6 ++ src/microbots/auto_memory/workspace.py | 4 +- .../runners/test_writing_bot_runner.py | 4 +- test/auto_memory/test_cli.py | 94 +++++++++++++++++++ test/auto_memory/test_config.py | 44 +++++++++ 8 files changed, 274 insertions(+), 6 deletions(-) diff --git a/src/microbots/auto_memory/cli.py b/src/microbots/auto_memory/cli.py index 3cccfd1..3f8dc9b 100644 --- a/src/microbots/auto_memory/cli.py +++ b/src/microbots/auto_memory/cli.py @@ -2,6 +2,8 @@ from __future__ import annotations +import importlib +import importlib.util import uuid from datetime import datetime, timezone from logging import getLogger @@ -9,13 +11,93 @@ from microbots.auto_memory.callbacks import ShellCallbackRunner from microbots.auto_memory.config import TaskConfig +from microbots.auto_memory.errors import ConfigError from microbots.auto_memory.orchestrator import RunSummary, TrainingLoopOrchestrator -from microbots.auto_memory.runners.writing_bot_runner import WritingBotRunner +from microbots.auto_memory.runners.base import AgentRunner from microbots.auto_memory.workspace import WorkspaceManager logger = getLogger(__name__) +def _load_runner_class(runner_spec: str, base_dir: Path) -> type: + """Resolve a runner class from a task config ``runner`` string. + + Two forms are supported: + + * **Dotted import path** — ``"pkg.module.ClassName"``. Imported via the + normal import system; the module must be importable (installed package + or on ``sys.path``). + * **File path plus class** — ``"path/to/file.py:ClassName"``. Loaded + directly from disk with :mod:`importlib.util`, so the runner can live + outside the microbots package. Relative file paths are resolved against + *base_dir* (the task YAML's directory). + + Parameters + ---------- + runner_spec : str + The ``runner`` value from the task configuration. + base_dir : Path + Directory used to resolve relative file paths (the task YAML's dir). + + Returns + ------- + type + The resolved runner class. + + Raises + ------ + ConfigError + If the spec is malformed, the module/file cannot be imported, or the + class is not found. + """ + if ":" in runner_spec: + # File-path form: ".py:" + file_part, _, cls_name = runner_spec.rpartition(":") + if not file_part or not cls_name: + raise ConfigError( + f"Invalid runner spec '{runner_spec}'; expected 'path/to/file.py:ClassName'" + ) + file_path = Path(file_part) + if not file_path.is_absolute(): + file_path = (base_dir / file_path).resolve() + if not file_path.is_file(): + raise ConfigError(f"Runner file not found: {file_path}") + + module_spec = importlib.util.spec_from_file_location( + "_microbots_user_runner", file_path + ) + if module_spec is None or module_spec.loader is None: + raise ConfigError(f"Cannot load runner module from {file_path}") + module = importlib.util.module_from_spec(module_spec) + try: + module_spec.loader.exec_module(module) + except Exception as exc: # noqa: BLE001 - surface any import-time error + raise ConfigError( + f"Failed to import runner file {file_path}: {exc}" + ) from exc + else: + # Dotted import path form: "pkg.module.ClassName" + module_path, _, cls_name = runner_spec.rpartition(".") + if not module_path or not cls_name: + raise ConfigError( + f"Invalid runner spec '{runner_spec}'; expected " + f"'pkg.module.ClassName' or 'path/to/file.py:ClassName'" + ) + try: + module = importlib.import_module(module_path) + except ImportError as exc: + raise ConfigError( + f"Cannot import runner module '{module_path}': {exc}" + ) from exc + + try: + return getattr(module, cls_name) + except AttributeError as exc: + raise ConfigError( + f"Runner class '{cls_name}' not found in '{runner_spec}'" + ) from exc + + def run_from_yaml( yaml_path: str | Path, workdir: str | Path, @@ -49,7 +131,7 @@ def run_from_yaml( random suffix of the form ``run-YYYYMMDD-HHMMSS-ffffff-`` is generated to avoid collisions. model : str - Model identifier forwarded to :class:`WritingBotRunner` (required, + Model identifier forwarded to the configured runner (required, keyword-only — e.g. ``"azure-openai/gpt-4o"``). Returns @@ -57,6 +139,7 @@ def run_from_yaml( RunSummary Summary of the completed run. """ + yaml_path = Path(yaml_path) config = TaskConfig.load_from_yaml(str(yaml_path)) if run_id is None: @@ -65,8 +148,11 @@ def run_from_yaml( run_dir = Path(workdir) / "runs" / run_id logger.info("auto_memory: starting run %s at %s", run_id, run_dir) + runner_cls = _load_runner_class(config.runner, base_dir=yaml_path.resolve().parent) + agent_runner: AgentRunner = runner_cls(model=model, **config.runner_params) + logger.info("auto_memory: using runner %s", config.runner) + workspace = WorkspaceManager(run_dir=run_dir) - agent_runner = WritingBotRunner(model=model) callback_runner = ShellCallbackRunner() orchestrator = TrainingLoopOrchestrator( diff --git a/src/microbots/auto_memory/config.py b/src/microbots/auto_memory/config.py index 75e2159..056912a 100644 --- a/src/microbots/auto_memory/config.py +++ b/src/microbots/auto_memory/config.py @@ -36,6 +36,15 @@ class TaskConfig: analyzer_max_iterations: int = 20 analyzer_timeout_s: int = 300 + # --- runner selection --- + # Either a dotted import path ("pkg.module.ClassName") or a file-path form + # ("path/to/file.py:ClassName") resolved relative to the task YAML's dir. + # Defaults to the built-in WritingBotRunner so existing configs are + # unaffected. Extra keyword arguments for the runner's constructor go in + # runner_params (model is always injected separately by the CLI). + runner: str = "microbots.auto_memory.runners.writing_bot_runner.WritingBotRunner" + runner_params: dict = field(default_factory=dict) + # ----------------------------------------------------------------------- @classmethod @@ -112,6 +121,13 @@ def load_from_yaml(cls, path: str) -> "TaskConfig": analyzer_model=str(data.get("analyzer_model", "azure-openai/gpt-4o")), analyzer_max_iterations=int(data.get("analyzer_max_iterations", 20)), analyzer_timeout_s=int(data.get("analyzer_timeout_s", 300)), + runner=str( + data.get( + "runner", + "microbots.auto_memory.runners.writing_bot_runner.WritingBotRunner", + ) + ), + runner_params=dict(data.get("runner_params", {}) or {}), ) config.validate() return config @@ -201,3 +217,17 @@ def validate(self) -> None: raise ConfigError( f"reference_input '{ri.name}' must have a non-empty 'value'" ) + + if not self.runner or not self.runner.strip(): + raise ConfigError("'runner' must not be empty") + + if not isinstance(self.runner_params, dict): + raise ConfigError( + f"'runner_params' must be a mapping, got {type(self.runner_params).__name__}" + ) + + if "model" in self.runner_params: + raise ConfigError( + "'runner_params' must not contain 'model'; it is injected " + "automatically by the CLI" + ) diff --git a/src/microbots/auto_memory/orchestrator.py b/src/microbots/auto_memory/orchestrator.py index 752254d..b70a977 100644 --- a/src/microbots/auto_memory/orchestrator.py +++ b/src/microbots/auto_memory/orchestrator.py @@ -302,7 +302,11 @@ def run_iteration( feedback=feedback, ) - ctx = IterationContext(task=task_prompt, memory_dir=memory_dir) + ctx = IterationContext( + task=task_prompt, + memory_dir=memory_dir, + output_dir=str(candidate_path), + ) # Run agent agent_result: AgentResult = self._agent_runner.run( diff --git a/src/microbots/auto_memory/runners/base.py b/src/microbots/auto_memory/runners/base.py index a7fb715..93e4db5 100644 --- a/src/microbots/auto_memory/runners/base.py +++ b/src/microbots/auto_memory/runners/base.py @@ -19,10 +19,16 @@ class IterationContext: memory_dir : str Host-side directory that is both mounted into the agent container (``folder_to_mount``) and surfaced to the agent via :class:`~microbots.tools.tool_definitions.memory_tool.MemoryTool`. + output_dir : str + Host-side directory (the iteration's ``candidate`` path) where the + runner should write the artefact that callbacks validate. Callbacks + receive this same path as ``$CANDIDATE``, so a runner that produces a + file/tree here closes the produce → validate loop. """ task: str memory_dir: str + output_dir: str @dataclass diff --git a/src/microbots/auto_memory/workspace.py b/src/microbots/auto_memory/workspace.py index d403c65..58bc90b 100644 --- a/src/microbots/auto_memory/workspace.py +++ b/src/microbots/auto_memory/workspace.py @@ -26,7 +26,9 @@ class WorkspaceManager: / ├── memory/ - │ └── feedback.jsonl ← managed by MemoryStore + │ ├── feedback.jsonl ← managed by MemoryStore (framework) + │ └── ← written by MemoryTool; the agent's + │ /memories/… tree maps 1:1 here ├── iterations/ │ ├── iter_00/ │ │ ├── candidate/ ← agent writes output here diff --git a/test/auto_memory/runners/test_writing_bot_runner.py b/test/auto_memory/runners/test_writing_bot_runner.py index d83a583..e327ef0 100644 --- a/test/auto_memory/runners/test_writing_bot_runner.py +++ b/test/auto_memory/runners/test_writing_bot_runner.py @@ -18,7 +18,9 @@ def _make_ctx(memory_dir: str, task: str = _TASK) -> IterationContext: - return IterationContext(task=task, memory_dir=memory_dir) + return IterationContext( + task=task, memory_dir=memory_dir, output_dir=memory_dir + ) # --------------------------------------------------------------------------- diff --git a/test/auto_memory/test_cli.py b/test/auto_memory/test_cli.py index 889ccf1..d810e29 100644 --- a/test/auto_memory/test_cli.py +++ b/test/auto_memory/test_cli.py @@ -9,7 +9,9 @@ import pytest from microbots.auto_memory import run_from_yaml +from microbots.auto_memory.cli import _load_runner_class from microbots.auto_memory.data_models import FinalStatus +from microbots.auto_memory.errors import ConfigError from microbots.auto_memory.orchestrator import RunSummary from microbots.MicroBot import BotRunResult @@ -189,3 +191,95 @@ def test_failing_callbacks_persist_feedback_and_reach_limit(self, tmp_path): lines = [ln for ln in feedback_file.read_text().splitlines() if ln.strip()] assert len(lines) == 2 assert (run_dir / "iterations" / "iter_01" / "candidate").is_dir() + + +# --------------------------------------------------------------------------- +# Runner resolution (_load_runner_class) +# --------------------------------------------------------------------------- + +_RUNNER_FILE_SRC = textwrap.dedent("""\ + class MyRunner: + def __init__(self, model, **kwargs): + self.model = model + self.kwargs = kwargs + + def run(self, ctx, timeout_s): + return None +""") + + +@pytest.mark.unit +class TestLoadRunnerClass: + def _write_runner(self, tmp_path: Path, name: str = "myrunner.py") -> Path: + p = tmp_path / name + p.write_text(_RUNNER_FILE_SRC) + return p + + # --- file-path form ------------------------------------------------- + def test_file_path_form_loads_class(self, tmp_path): + self._write_runner(tmp_path) + cls = _load_runner_class("myrunner.py:MyRunner", base_dir=tmp_path) + assert cls.__name__ == "MyRunner" + instance = cls(model=_MODEL, repo_url="x") + assert instance.model == _MODEL + assert instance.kwargs == {"repo_url": "x"} + + def test_file_path_form_absolute(self, tmp_path): + runner = self._write_runner(tmp_path) + cls = _load_runner_class(f"{runner}:MyRunner", base_dir=Path("/nonexistent")) + assert cls.__name__ == "MyRunner" + + def test_file_path_missing_class_name(self, tmp_path): + self._write_runner(tmp_path) + with pytest.raises(ConfigError, match="expected 'path/to/file.py:ClassName'"): + _load_runner_class("myrunner.py:", base_dir=tmp_path) + + def test_file_path_missing_file_part(self, tmp_path): + with pytest.raises(ConfigError, match="expected 'path/to/file.py:ClassName'"): + _load_runner_class(":MyRunner", base_dir=tmp_path) + + def test_file_not_found(self, tmp_path): + with pytest.raises(ConfigError, match="Runner file not found"): + _load_runner_class("does_not_exist.py:MyRunner", base_dir=tmp_path) + + def test_file_import_error(self, tmp_path): + bad = tmp_path / "bad_runner.py" + bad.write_text("raise RuntimeError('boom')\n") + with pytest.raises(ConfigError, match="Failed to import runner file"): + _load_runner_class("bad_runner.py:MyRunner", base_dir=tmp_path) + + def test_file_spec_none(self, tmp_path): + self._write_runner(tmp_path) + with patch( + "microbots.auto_memory.cli.importlib.util.spec_from_file_location", + return_value=None, + ): + with pytest.raises(ConfigError, match="Cannot load runner module"): + _load_runner_class("myrunner.py:MyRunner", base_dir=tmp_path) + + def test_file_class_not_found(self, tmp_path): + self._write_runner(tmp_path) + with pytest.raises(ConfigError, match="not found"): + _load_runner_class("myrunner.py:NoSuchRunner", base_dir=tmp_path) + + # --- dotted import path form --------------------------------------- + def test_dotted_form_loads_class(self, tmp_path): + cls = _load_runner_class( + "microbots.auto_memory.runners.writing_bot_runner.WritingBotRunner", + base_dir=tmp_path, + ) + assert cls.__name__ == "WritingBotRunner" + + def test_dotted_form_missing_module(self, tmp_path): + with pytest.raises(ConfigError, match="expected"): + _load_runner_class("WritingBotRunner", base_dir=tmp_path) + + def test_dotted_form_import_error(self, tmp_path): + with pytest.raises(ConfigError, match="Cannot import runner module"): + _load_runner_class("no_such_pkg.module.Klass", base_dir=tmp_path) + + def test_dotted_form_class_not_found(self, tmp_path): + with pytest.raises(ConfigError, match="not found"): + _load_runner_class( + "microbots.auto_memory.config.NoSuchClass", base_dir=tmp_path + ) diff --git a/test/auto_memory/test_config.py b/test/auto_memory/test_config.py index 412db1d..3f0877c 100644 --- a/test/auto_memory/test_config.py +++ b/test/auto_memory/test_config.py @@ -93,6 +93,32 @@ def test_defaults_applied(self, tmp_yaml): assert cfg.output_path == "candidate" assert cfg.reference_inputs == [] + def test_runner_defaults_to_writing_bot(self, tmp_yaml): + cfg = TaskConfig.load_from_yaml(tmp_yaml(MINIMAL_YAML)) + assert cfg.runner == ( + "microbots.auto_memory.runners.writing_bot_runner.WritingBotRunner" + ) + assert cfg.runner_params == {} + + def test_runner_fields_parsed(self, tmp_yaml): + yaml = textwrap.dedent("""\ + task_definition: Backport the patch + prompt_template: "Goal: {{ task }}" + runner: ./backport_runner.py:BackportRunner + runner_params: + repo_url: https://example.com/repo.git + target_commit: abc123 + callbacks: + - name: tests + command: pytest + """) + cfg = TaskConfig.load_from_yaml(tmp_yaml(yaml)) + assert cfg.runner == "./backport_runner.py:BackportRunner" + assert cfg.runner_params == { + "repo_url": "https://example.com/repo.git", + "target_commit": "abc123", + } + def test_missing_callbacks(self, tmp_yaml): yaml = textwrap.dedent("""\ task_definition: Fix the bug @@ -253,3 +279,21 @@ def test_empty_callbacks_list(self): cfg.callbacks = [] with pytest.raises(ConfigError, match="callbacks"): cfg.validate() + + def test_runner_empty(self): + cfg = self._base() + cfg.runner = " " + with pytest.raises(ConfigError, match="runner"): + cfg.validate() + + def test_runner_params_not_a_dict(self): + cfg = self._base() + cfg.runner_params = ["not", "a", "dict"] + with pytest.raises(ConfigError, match="runner_params.*mapping"): + cfg.validate() + + def test_runner_params_reject_model_key(self): + cfg = self._base() + cfg.runner_params = {"model": "azure-openai/gpt-4o"} + with pytest.raises(ConfigError, match="runner_params.*model"): + cfg.validate() From ff07edcabfecdf9f319dadd50f457f2e867c5dd8 Mon Sep 17 00:00:00 2001 From: Kavya Sree Kaitepalli Date: Mon, 13 Jul 2026 09:05:23 +0000 Subject: [PATCH 2/2] resolve comments --- src/microbots/auto_memory/cli.py | 45 +++++++++++++--- src/microbots/auto_memory/config.py | 5 +- test/auto_memory/test_cli.py | 79 +++++++++++++++++++++++++++++ test/auto_memory/test_config.py | 29 +++++++++++ 4 files changed, 150 insertions(+), 8 deletions(-) diff --git a/src/microbots/auto_memory/cli.py b/src/microbots/auto_memory/cli.py index 3f8dc9b..32c8204 100644 --- a/src/microbots/auto_memory/cli.py +++ b/src/microbots/auto_memory/cli.py @@ -8,6 +8,7 @@ from datetime import datetime, timezone from logging import getLogger from pathlib import Path +from typing import Callable from microbots.auto_memory.callbacks import ShellCallbackRunner from microbots.auto_memory.config import TaskConfig @@ -19,7 +20,7 @@ logger = getLogger(__name__) -def _load_runner_class(runner_spec: str, base_dir: Path) -> type: +def _load_runner_class(runner_spec: str, base_dir: Path) -> Callable[..., AgentRunner]: """Resolve a runner class from a task config ``runner`` string. Two forms are supported: @@ -41,14 +42,16 @@ def _load_runner_class(runner_spec: str, base_dir: Path) -> type: Returns ------- - type - The resolved runner class. + Callable[..., AgentRunner] + The resolved runner factory — a class or any callable that accepts + ``model=...`` plus ``runner_params`` and returns an + :class:`~microbots.auto_memory.runners.base.AgentRunner`. Raises ------ ConfigError - If the spec is malformed, the module/file cannot be imported, or the - class is not found. + If the spec is malformed, the module/file cannot be imported, the + class is not found, or the resolved attribute is not callable. """ if ":" in runner_spec: # File-path form: ".py:" @@ -89,14 +92,26 @@ class is not found. raise ConfigError( f"Cannot import runner module '{module_path}': {exc}" ) from exc + except Exception as exc: # noqa: BLE001 - surface import-time errors + raise ConfigError( + f"Failed to import runner module '{module_path}': {exc}" + ) from exc try: - return getattr(module, cls_name) + runner_obj = getattr(module, cls_name) except AttributeError as exc: raise ConfigError( f"Runner class '{cls_name}' not found in '{runner_spec}'" ) from exc + if not callable(runner_obj): + raise ConfigError( + f"Runner '{cls_name}' in '{runner_spec}' is not callable " + f"(got {type(runner_obj).__name__}); expected a class or factory " + f"that accepts model=... and returns an AgentRunner" + ) + return runner_obj + def run_from_yaml( yaml_path: str | Path, @@ -149,7 +164,23 @@ def run_from_yaml( logger.info("auto_memory: starting run %s at %s", run_id, run_dir) runner_cls = _load_runner_class(config.runner, base_dir=yaml_path.resolve().parent) - agent_runner: AgentRunner = runner_cls(model=model, **config.runner_params) + try: + agent_runner: AgentRunner = runner_cls(model=model, **config.runner_params) + except Exception as exc: # noqa: BLE001 - surface construction errors as config errors + raise ConfigError( + f"Failed to construct runner '{config.runner}' with " + f"runner_params={config.runner_params!r}: {exc}" + ) from exc + + # Structural check: the constructed object must satisfy the AgentRunner + # protocol (i.e. expose a run() method). This only verifies method + # presence, not its signature, but catches gross misconfigurations early + # with a clear error instead of failing deep inside the orchestrator. + if not isinstance(agent_runner, AgentRunner): + raise ConfigError( + f"Runner '{config.runner}' does not satisfy the AgentRunner " + f"protocol; it must define run(ctx, timeout_s)" + ) logger.info("auto_memory: using runner %s", config.runner) workspace = WorkspaceManager(run_dir=run_dir) diff --git a/src/microbots/auto_memory/config.py b/src/microbots/auto_memory/config.py index 056912a..822d1c2 100644 --- a/src/microbots/auto_memory/config.py +++ b/src/microbots/auto_memory/config.py @@ -127,7 +127,10 @@ def load_from_yaml(cls, path: str) -> "TaskConfig": "microbots.auto_memory.runners.writing_bot_runner.WritingBotRunner", ) ), - runner_params=dict(data.get("runner_params", {}) or {}), + # Pass through as-is (no dict() coercion) so a non-mapping value + # reaches validate() and produces a clear ConfigError instead of a + # cryptic ValueError/TypeError from dict(). + runner_params=data.get("runner_params", {}) or {}, ) config.validate() return config diff --git a/test/auto_memory/test_cli.py b/test/auto_memory/test_cli.py index d810e29..6c33344 100644 --- a/test/auto_memory/test_cli.py +++ b/test/auto_memory/test_cli.py @@ -193,6 +193,65 @@ def test_failing_callbacks_persist_feedback_and_reach_limit(self, tmp_path): assert (run_dir / "iterations" / "iter_01" / "candidate").is_dir() +# --------------------------------------------------------------------------- +# Runner construction guards (run_from_yaml) +# --------------------------------------------------------------------------- + +_GOOD_RUNNER_SRC = textwrap.dedent("""\ + class R: + def __init__(self, model): + self.model = model + + def run(self, ctx, timeout_s): + return None +""") + +_NO_RUN_RUNNER_SRC = textwrap.dedent("""\ + class R: + def __init__(self, model): + self.model = model +""") + + +def _write_custom_runner_yaml(tmp_path: Path, runner_src: str, runner_params: str) -> Path: + (tmp_path / "custom_runner.py").write_text(runner_src) + yaml = textwrap.dedent(f"""\ + task_definition: do a thing + prompt_template: "Goal: {{{{ task }}}}" + runner: ./custom_runner.py:R + runner_params: + {runner_params} + callbacks: + - name: always_ok + command: 'true' + max_iterations: 1 + timeout_min: 1 + per_iteration_timeout: 30 + """) + p = tmp_path / "task.yml" + p.write_text(yaml) + return p + + +@pytest.mark.unit +class TestRunnerConstructionGuards: + def test_bad_runner_params_raises_config_error(self, tmp_path): + """runner_params that don't match __init__ surface as ConfigError.""" + yaml_path = _write_custom_runner_yaml( + tmp_path, _GOOD_RUNNER_SRC, runner_params=" unexpected_kwarg: 1" + ) + with pytest.raises(ConfigError, match="Failed to construct runner"): + run_from_yaml(str(yaml_path), str(tmp_path / "wd"), model=_MODEL) + + def test_runner_missing_run_raises_config_error(self, tmp_path): + """A runner without run() fails the AgentRunner protocol check.""" + yaml_path = _write_custom_runner_yaml( + tmp_path, _NO_RUN_RUNNER_SRC, runner_params=" {}" + ) + with pytest.raises(ConfigError, match="AgentRunner"): + run_from_yaml(str(yaml_path), str(tmp_path / "wd"), model=_MODEL) + + # --------------------------------------------------------------------------- # Runner resolution (_load_runner_class) # --------------------------------------------------------------------------- @@ -278,8 +337,28 @@ def test_dotted_form_import_error(self, tmp_path): with pytest.raises(ConfigError, match="Cannot import runner module"): _load_runner_class("no_such_pkg.module.Klass", base_dir=tmp_path) + def test_dotted_form_import_time_error(self, tmp_path): + """A non-ImportError raised while importing the module is wrapped in + ConfigError instead of escaping as a raw traceback.""" + with patch( + "microbots.auto_memory.cli.importlib.import_module", + side_effect=RuntimeError("boom at import"), + ): + with pytest.raises(ConfigError, match="Failed to import runner module"): + _load_runner_class( + "microbots.auto_memory.config.TaskConfig", base_dir=tmp_path + ) + def test_dotted_form_class_not_found(self, tmp_path): with pytest.raises(ConfigError, match="not found"): _load_runner_class( "microbots.auto_memory.config.NoSuchClass", base_dir=tmp_path ) + + def test_resolved_attribute_not_callable(self, tmp_path): + """A resolved attribute that is not callable (e.g. a constant) raises a + clear ConfigError instead of a later cryptic TypeError.""" + const_file = tmp_path / "const_runner.py" + const_file.write_text("NOT_A_RUNNER = 42\n") + with pytest.raises(ConfigError, match="not callable"): + _load_runner_class("const_runner.py:NOT_A_RUNNER", base_dir=tmp_path) diff --git a/test/auto_memory/test_config.py b/test/auto_memory/test_config.py index 3f0877c..cf6e80f 100644 --- a/test/auto_memory/test_config.py +++ b/test/auto_memory/test_config.py @@ -119,6 +119,35 @@ def test_runner_fields_parsed(self, tmp_yaml): "target_commit": "abc123", } + def test_runner_params_non_mapping_raises_config_error(self, tmp_yaml): + """A non-mapping runner_params in YAML surfaces as ConfigError, not a + raw ValueError/TypeError from dict() coercion.""" + yaml = textwrap.dedent("""\ + task_definition: Backport the patch + prompt_template: "Goal: {{ task }}" + runner_params: + - not + - a + - mapping + callbacks: + - name: tests + command: pytest + """) + with pytest.raises(ConfigError, match="runner_params.*mapping"): + TaskConfig.load_from_yaml(tmp_yaml(yaml)) + + def test_runner_params_scalar_raises_config_error(self, tmp_yaml): + yaml = textwrap.dedent("""\ + task_definition: Backport the patch + prompt_template: "Goal: {{ task }}" + runner_params: 5 + callbacks: + - name: tests + command: pytest + """) + with pytest.raises(ConfigError, match="runner_params.*mapping"): + TaskConfig.load_from_yaml(tmp_yaml(yaml)) + def test_missing_callbacks(self, tmp_yaml): yaml = textwrap.dedent("""\ task_definition: Fix the bug