From 7ccf28166ca4ec7502b15536f41ad3632b4f5a20 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 8 Mar 2026 03:08:23 +0000 Subject: [PATCH 1/7] feat: Rust recipe runner integration with engine selection Adds the Rust recipe runner binary integration with automatic engine selection and startup dependency management. - src/amplihack/recipes/rust_runner.py: Binary wrapper with find, ensure, and execute functions. RustRunnerNotFoundError for explicit failures. ensure_rust_recipe_runner() auto-installs via cargo if binary is missing. - src/amplihack/recipes/__init__.py: Engine selection via RECIPE_RUNNER_ENGINE env var (rust/python/auto-detect). Exports ensure_rust_recipe_runner. - src/amplihack/install.py: Step 6.5 ensures binary during amplihack install. - tests/recipes/test_rust_runner.py: 26 tests covering discovery, execution, engine selection, and ensure flow. - docs/recipes/README.md: Documents engine selection and auto-install. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 3 + docs/recipes/README.md | 33 ++- src/amplihack/install.py | 13 ++ src/amplihack/recipes/__init__.py | 55 ++++- src/amplihack/recipes/rust_runner.py | 207 +++++++++++++++++ tests/recipes/test_rust_runner.py | 317 +++++++++++++++++++++++++++ 6 files changed, 625 insertions(+), 3 deletions(-) create mode 100644 src/amplihack/recipes/rust_runner.py create mode 100644 tests/recipes/test_rust_runner.py diff --git a/.gitignore b/.gitignore index ed46056ee..907701e06 100644 --- a/.gitignore +++ b/.gitignore @@ -210,3 +210,6 @@ eval_results.json *_results/ eval_progressive_example/ generated-agents/ + +# Rust recipe runner repo checkout +amplihack-recipe-runner-rs/ diff --git a/docs/recipes/README.md b/docs/recipes/README.md index f2311b072..bece68fef 100644 --- a/docs/recipes/README.md +++ b/docs/recipes/README.md @@ -1,6 +1,37 @@ # Recipe Runner -A code-enforced workflow execution engine that reads declarative YAML recipe files and executes them step-by-step using AI agents. Unlike prompt-based workflow instructions that models can interpret loosely or skip, the Recipe Runner controls the execution loop in Python code -- making it physically impossible to skip steps. +A code-enforced workflow execution engine that reads declarative YAML recipe files and executes them step-by-step using AI agents. Unlike prompt-based workflow instructions that models can interpret loosely or skip, the Recipe Runner controls the execution loop in compiled code โ€” making it physically impossible to skip steps. + +**Standalone repo & docs**: [github.com/rysweet/amplihack-recipe-runner](https://github.com/rysweet/amplihack-recipe-runner) ยท [rysweet.github.io/amplihack-recipe-runner](https://rysweet.github.io/amplihack-recipe-runner/) + +## Engine Selection + +The recipe runner supports two engines. Set `RECIPE_RUNNER_ENGINE` to choose explicitly: + +| Value | Engine | Notes | +|-------|--------|-------| +| `rust` | [recipe-runner-rs](https://github.com/rysweet/amplihack-recipe-runner) | Standalone binary, ~5ms startup, 216 tests | +| `python` | Built-in Python runner | No extra install needed | +| *(not set)* | Auto-detect | Uses Rust if binary found in PATH, Python otherwise | + +```bash +# Install the Rust binary +cargo install --git https://github.com/rysweet/amplihack-recipe-runner + +# Or set path explicitly +export RECIPE_RUNNER_RS_PATH=/path/to/recipe-runner-rs + +# Force a specific engine +export RECIPE_RUNNER_ENGINE=rust # or python +``` + +The Rust binary is automatically installed during `amplihack install` if `cargo` is available. To check or manually trigger installation: + +```python +from amplihack.recipes import ensure_rust_recipe_runner + +ensure_rust_recipe_runner() # Installs if missing, no-op if present +``` ## Contents diff --git a/src/amplihack/install.py b/src/amplihack/install.py index 61d4afbc0..50b53e3c6 100644 --- a/src/amplihack/install.py +++ b/src/amplihack/install.py @@ -404,6 +404,19 @@ class StagingManifest: hooks_ok = verify_hooks() + # Step 6.5: Ensure Rust recipe runner binary + print("\n๐Ÿฆ€ Ensuring Rust recipe runner:") + try: + from .recipes.rust_runner import ensure_rust_recipe_runner + + if ensure_rust_recipe_runner(): + print(" โœ… recipe-runner-rs is available") + else: + print(" โš ๏ธ recipe-runner-rs not installed (Python runner will be used)") + print(" Install manually: cargo install --git https://github.com/rysweet/amplihack-recipe-runner") + except Exception as e: + print(f" โš ๏ธ Could not check recipe-runner-rs: {e}") + # Step 7: Generate manifest for uninstall print("\n๐Ÿ“ Generating uninstall manifest:") diff --git a/src/amplihack/recipes/__init__.py b/src/amplihack/recipes/__init__.py index 692977379..4468f2377 100644 --- a/src/amplihack/recipes/__init__.py +++ b/src/amplihack/recipes/__init__.py @@ -3,9 +3,10 @@ Public API: - ``parse_recipe(yaml_content)`` -- shortcut to parse a YAML string - ``run_recipe(yaml_content, adapter, **kwargs)`` -- parse and execute in one call + - ``run_recipe_by_name(name, adapter, **kwargs)`` -- find, parse, and execute - ``list_recipes()`` -- discover all available recipes - ``find_recipe(name)`` -- find a recipe file by name - - ``RecipeRunner`` -- the core execution engine + - ``RecipeRunner`` -- the core execution engine (Python) - ``RecipeParser`` -- YAML-to-Recipe parser - ``RecipeContext`` -- template-rendering execution context """ @@ -38,6 +39,14 @@ from amplihack.recipes.parser import RecipeParser from amplihack.recipes.runner import RecipeRunner +from amplihack.recipes.rust_runner import ( + RustRunnerNotFoundError, + ensure_rust_recipe_runner, + find_rust_binary, + is_rust_runner_available, + run_recipe_via_rust, +) + __all__ = [ "AgentNotFoundError", "AgentResolver", @@ -47,6 +56,7 @@ "RecipeRunner", "Recipe", "RecipeResult", + "RustRunnerNotFoundError", "Step", "StepExecutionError", "StepResult", @@ -54,11 +64,15 @@ "StepType", "check_upstream_changes", "discover_recipes", + "ensure_rust_recipe_runner", "find_recipe", + "find_rust_binary", + "is_rust_runner_available", "list_recipes", "parse_recipe", "run_recipe", "run_recipe_by_name", + "run_recipe_via_rust", "sync_upstream", "update_manifest", "verify_global_installation", @@ -90,15 +104,52 @@ def run_recipe_by_name( ) -> RecipeResult: """Find a recipe by name, parse it, and execute it. + Engine selection (no fallbacks โ€” chosen engine must succeed or fail): + + - ``RECIPE_RUNNER_ENGINE=rust`` โ†’ Rust binary only (fails if not installed) + - ``RECIPE_RUNNER_ENGINE=python`` โ†’ Python runner only + - Not set โ†’ auto-detect once: Rust if binary exists, Python otherwise. + Logs which engine was selected. + Args: name: Recipe name (e.g. ``"default-workflow"``). - adapter: SDK adapter for step execution. + adapter: SDK adapter for step execution (used by Python engine). user_context: Context variable overrides. dry_run: If True, log steps without executing. Raises: FileNotFoundError: If no recipe with that name is found. + RustRunnerNotFoundError: If engine is 'rust' but binary is missing. """ + import os + + engine = os.environ.get("RECIPE_RUNNER_ENGINE", "").lower() + + if engine == "rust": + return run_recipe_via_rust(name=name, user_context=user_context, dry_run=dry_run) + + if engine == "python": + return _run_recipe_python(name, adapter, user_context, dry_run) + + # Auto-detect: check once, commit to the result, log clearly + import logging + _log = logging.getLogger(__name__) + + if is_rust_runner_available(): + _log.info("RECIPE_RUNNER_ENGINE not set โ€” auto-selected 'rust' (binary found in PATH)") + return run_recipe_via_rust(name=name, user_context=user_context, dry_run=dry_run) + + _log.info("RECIPE_RUNNER_ENGINE not set โ€” auto-selected 'python' (rust binary not found)") + return _run_recipe_python(name, adapter, user_context, dry_run) + + +def _run_recipe_python( + name: str, + adapter: Any, + user_context: dict[str, Any] | None, + dry_run: bool, +) -> RecipeResult: + """Execute a recipe using the Python runner.""" path = find_recipe(name) if path is None: raise FileNotFoundError(f"Recipe '{name}' not found in any search directory") diff --git a/src/amplihack/recipes/rust_runner.py b/src/amplihack/recipes/rust_runner.py new file mode 100644 index 000000000..a0f00daa0 --- /dev/null +++ b/src/amplihack/recipes/rust_runner.py @@ -0,0 +1,207 @@ +"""Rust recipe runner integration. + +Delegates recipe execution to the ``recipe-runner-rs`` binary. +No fallbacks โ€” if the Rust engine is selected and the binary is missing, +execution fails immediately with a clear error. +""" + +from __future__ import annotations + +import json +import logging +import os +import shutil +import subprocess +from pathlib import Path +from typing import Any + +from amplihack.recipes.models import RecipeResult, StepResult, StepStatus + +logger = logging.getLogger(__name__) + +# Known locations to search for the Rust binary +_BINARY_SEARCH_PATHS = [ + "recipe-runner-rs", # PATH + str(Path.home() / ".cargo" / "bin" / "recipe-runner-rs"), + str(Path.home() / ".local" / "bin" / "recipe-runner-rs"), +] + + +def find_rust_binary() -> str | None: + """Find the recipe-runner-rs binary. + + Checks the RECIPE_RUNNER_RS_PATH env var first, then known locations. + Returns the path to the binary, or None if not found. + """ + env_path = os.environ.get("RECIPE_RUNNER_RS_PATH") + if env_path and shutil.which(env_path): + return env_path + + for candidate in _BINARY_SEARCH_PATHS: + resolved = shutil.which(candidate) + if resolved: + return resolved + + return None + + +def is_rust_runner_available() -> bool: + """Check if the Rust recipe runner binary is available.""" + return find_rust_binary() is not None + + +class RustRunnerNotFoundError(RuntimeError): + """Raised when the Rust recipe runner binary is required but not found.""" + + +_REPO_URL = "https://github.com/rysweet/amplihack-recipe-runner" + + +def ensure_rust_recipe_runner(*, quiet: bool = False) -> bool: + """Ensure the recipe-runner-rs binary is installed. + + If the binary is already available, returns True immediately. + Otherwise, attempts to install via ``cargo install --git``. + + Args: + quiet: Suppress progress messages. + + Returns: + True if binary is available after this call, False if installation failed. + """ + if is_rust_runner_available(): + return True + + cargo = shutil.which("cargo") + if cargo is None: + if not quiet: + logger.warning( + "cargo not found โ€” cannot auto-install recipe-runner-rs. " + "Install Rust (https://rustup.rs) then run: " + "cargo install --git %s", + _REPO_URL, + ) + return False + + if not quiet: + logger.info("Installing recipe-runner-rs from %s โ€ฆ", _REPO_URL) + + try: + result = subprocess.run( + [cargo, "install", "--git", _REPO_URL], + capture_output=True, + text=True, + timeout=300, + ) + if result.returncode == 0: + if not quiet: + logger.info("recipe-runner-rs installed successfully") + return True + + logger.warning( + "cargo install failed (exit %d): %s", + result.returncode, + result.stderr[:500] if result.stderr else "no output", + ) + return False + except subprocess.TimeoutExpired: + logger.warning("cargo install timed out after 300s") + return False + except Exception as exc: + logger.warning("cargo install failed: %s", exc) + return False + + +def run_recipe_via_rust( + name: str, + user_context: dict[str, Any] | None = None, + dry_run: bool = False, + recipe_dirs: list[str] | None = None, + working_dir: str = ".", + auto_stage: bool = True, +) -> RecipeResult: + """Execute a recipe using the Rust binary. + + Raises: + RustRunnerNotFoundError: If the binary is not installed. + RuntimeError: If the binary produces unparseable output. + """ + binary = find_rust_binary() + if binary is None: + raise RustRunnerNotFoundError( + "recipe-runner-rs binary not found. " + "Install it: cargo install --git https://github.com/rysweet/amplihack-recipe-runner " + "or set RECIPE_RUNNER_RS_PATH to the binary location." + ) + + cmd = [binary, name, "--output-format", "json", "-C", working_dir] + + if dry_run: + cmd.append("--dry-run") + + if not auto_stage: + cmd.append("--no-auto-stage") + + if recipe_dirs: + for d in recipe_dirs: + cmd.extend(["-R", d]) + + if user_context: + for key, value in user_context.items(): + if isinstance(value, (dict, list)): + cmd.extend(["--set", f"{key}={json.dumps(value)}"]) + elif isinstance(value, bool): + cmd.extend(["--set", f"{key}={'true' if value else 'false'}"]) + else: + cmd.extend(["--set", f"{key}={value}"]) + + logger.info("Executing recipe '%s' via Rust binary: %s", name, " ".join(cmd)) + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=working_dir, + timeout=3600, # 1 hour hard limit โ€” recipes can be long-running + ) + + # Parse JSON output + try: + data = json.loads(result.stdout) + except (json.JSONDecodeError, TypeError): + if result.returncode != 0: + raise RuntimeError( + f"Rust recipe runner failed (exit {result.returncode}): " + f"{result.stderr[:1000] if result.stderr else 'no stderr'}" + ) + raise RuntimeError( + f"Rust recipe runner returned unparseable output (exit {result.returncode}): " + f"{result.stdout[:500] if result.stdout else 'empty stdout'}" + ) + + # Convert JSON output to RecipeResult + step_results = [] + for sr in data.get("step_results", []): + status_str = sr.get("status", "failed").lower() + status_map = { + "completed": StepStatus.COMPLETED, + "skipped": StepStatus.SKIPPED, + "failed": StepStatus.FAILED, + "pending": StepStatus.PENDING, + "running": StepStatus.RUNNING, + } + step_results.append( + StepResult( + step_id=sr.get("step_id", "unknown"), + status=status_map.get(status_str, StepStatus.FAILED), + output=sr.get("output", ""), + error=sr.get("error", ""), + ) + ) + + return RecipeResult( + recipe_name=data.get("recipe_name", name), + success=data.get("success", False), + step_results=step_results, + context=data.get("context", {}), + ) diff --git a/tests/recipes/test_rust_runner.py b/tests/recipes/test_rust_runner.py new file mode 100644 index 000000000..788f435b8 --- /dev/null +++ b/tests/recipes/test_rust_runner.py @@ -0,0 +1,317 @@ +"""Tests for the Rust recipe runner integration (rust_runner.py). + +Covers: +- Binary discovery (find_rust_binary, is_rust_runner_available) +- Recipe execution via Rust binary (run_recipe_via_rust) +- JSON output parsing and error handling +- Engine selection in run_recipe_by_name +""" + +from __future__ import annotations + +import json +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from amplihack.recipes.rust_runner import ( + RustRunnerNotFoundError, + ensure_rust_recipe_runner, + find_rust_binary, + is_rust_runner_available, + run_recipe_via_rust, +) +from amplihack.recipes.models import StepStatus + + +# ============================================================================ +# find_rust_binary +# ============================================================================ + + +class TestFindRustBinary: + """Tests for find_rust_binary().""" + + @patch.dict("os.environ", {"RECIPE_RUNNER_RS_PATH": "/usr/local/bin/recipe-runner-rs"}) + @patch("shutil.which", return_value="/usr/local/bin/recipe-runner-rs") + def test_env_var_takes_priority(self, mock_which): + result = find_rust_binary() + assert result == "/usr/local/bin/recipe-runner-rs" + + @patch.dict("os.environ", {"RECIPE_RUNNER_RS_PATH": "/nonexistent/binary"}) + @patch("shutil.which", return_value=None) + def test_env_var_invalid_returns_none(self, mock_which): + result = find_rust_binary() + assert result is None + + @patch.dict("os.environ", {}, clear=True) + @patch("shutil.which", side_effect=lambda p: "/usr/bin/recipe-runner-rs" if p == "recipe-runner-rs" else None) + def test_path_lookup(self, mock_which): + result = find_rust_binary() + assert result == "/usr/bin/recipe-runner-rs" + + @patch.dict("os.environ", {}, clear=True) + @patch("shutil.which", return_value=None) + def test_not_found(self, mock_which): + result = find_rust_binary() + assert result is None + + +class TestIsRustRunnerAvailable: + """Tests for is_rust_runner_available().""" + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + def test_available(self, mock_find): + assert is_rust_runner_available() is True + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value=None) + def test_not_available(self, mock_find): + assert is_rust_runner_available() is False + + +# ============================================================================ +# run_recipe_via_rust +# ============================================================================ + + +class TestRunRecipeViaRust: + """Tests for run_recipe_via_rust().""" + + def _make_rust_output(self, *, success=True, steps=None): + """Helper to create valid Rust binary JSON output.""" + if steps is None: + steps = [ + {"step_id": "s1", "status": "Completed", "output": "hello", "error": ""}, + ] + return json.dumps({ + "recipe_name": "test-recipe", + "success": success, + "step_results": steps, + "context": {"result": "done"}, + }) + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value=None) + def test_raises_when_binary_missing(self, mock_find): + with pytest.raises(RustRunnerNotFoundError, match="recipe-runner-rs binary not found"): + run_recipe_via_rust("test-recipe") + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run") + def test_successful_execution(self, mock_run, mock_find): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, + stdout=self._make_rust_output(), + stderr="", + ) + result = run_recipe_via_rust("test-recipe") + assert result.success is True + assert result.recipe_name == "test-recipe" + assert len(result.step_results) == 1 + assert result.step_results[0].step_id == "s1" + assert result.step_results[0].status == StepStatus.COMPLETED + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run") + def test_passes_dry_run_flag(self, mock_run, mock_find): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, + stdout=self._make_rust_output(), + stderr="", + ) + run_recipe_via_rust("test-recipe", dry_run=True) + cmd = mock_run.call_args[0][0] + assert "--dry-run" in cmd + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run") + def test_passes_no_auto_stage_flag(self, mock_run, mock_find): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, + stdout=self._make_rust_output(), + stderr="", + ) + run_recipe_via_rust("test-recipe", auto_stage=False) + cmd = mock_run.call_args[0][0] + assert "--no-auto-stage" in cmd + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run") + def test_passes_recipe_dirs(self, mock_run, mock_find): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, + stdout=self._make_rust_output(), + stderr="", + ) + run_recipe_via_rust("test-recipe", recipe_dirs=["/a", "/b"]) + cmd = mock_run.call_args[0][0] + assert "-R" in cmd + idx = cmd.index("-R") + assert cmd[idx + 1] == "/a" + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run") + def test_passes_context_values(self, mock_run, mock_find): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, + stdout=self._make_rust_output(), + stderr="", + ) + run_recipe_via_rust("test-recipe", user_context={ + "name": "world", + "verbose": True, + "data": {"key": "val"}, + }) + cmd = mock_run.call_args[0][0] + set_args = [cmd[i + 1] for i, v in enumerate(cmd) if v == "--set"] + assert "name=world" in set_args + assert "verbose=true" in set_args + assert any('"key"' in a for a in set_args) + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run") + def test_has_timeout(self, mock_run, mock_find): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, + stdout=self._make_rust_output(), + stderr="", + ) + run_recipe_via_rust("test-recipe") + assert mock_run.call_args[1].get("timeout") == 3600 + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run") + def test_nonzero_exit_with_bad_json_raises(self, mock_run, mock_find): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=1, + stdout="not json", + stderr="error: recipe failed", + ) + with pytest.raises(RuntimeError, match="Rust recipe runner failed"): + run_recipe_via_rust("test-recipe") + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run") + def test_zero_exit_with_bad_json_raises(self, mock_run, mock_find): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, + stdout="not json at all", + stderr="", + ) + with pytest.raises(RuntimeError, match="unparseable output"): + run_recipe_via_rust("test-recipe") + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run") + def test_status_mapping(self, mock_run, mock_find): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, + stdout=self._make_rust_output(steps=[ + {"step_id": "a", "status": "Completed", "output": "", "error": ""}, + {"step_id": "b", "status": "Skipped", "output": "", "error": ""}, + {"step_id": "c", "status": "Failed", "output": "", "error": "boom"}, + {"step_id": "d", "status": "unknown_status", "output": "", "error": ""}, + ]), + stderr="", + ) + result = run_recipe_via_rust("test-recipe") + assert result.step_results[0].status == StepStatus.COMPLETED + assert result.step_results[1].status == StepStatus.SKIPPED + assert result.step_results[2].status == StepStatus.FAILED + assert result.step_results[3].status == StepStatus.FAILED # unknown โ†’ FAILED + + +# ============================================================================ +# Engine selection (run_recipe_by_name) +# ============================================================================ + + +class TestEngineSelection: + """Tests for run_recipe_by_name engine selection.""" + + @patch.dict("os.environ", {"RECIPE_RUNNER_ENGINE": "rust"}) + @patch("amplihack.recipes.run_recipe_via_rust") + def test_explicit_rust_engine(self, mock_rust): + from amplihack.recipes import run_recipe_by_name + mock_rust.return_value = MagicMock() + run_recipe_by_name("test", adapter=MagicMock()) + mock_rust.assert_called_once() + + @patch.dict("os.environ", {"RECIPE_RUNNER_ENGINE": "python"}) + @patch("amplihack.recipes._run_recipe_python") + def test_explicit_python_engine(self, mock_python): + from amplihack.recipes import run_recipe_by_name + mock_python.return_value = MagicMock() + run_recipe_by_name("test", adapter=MagicMock()) + mock_python.assert_called_once() + + @patch.dict("os.environ", {}, clear=True) + @patch("amplihack.recipes.is_rust_runner_available", return_value=True) + @patch("amplihack.recipes.run_recipe_via_rust") + def test_auto_detect_prefers_rust(self, mock_rust, mock_avail): + from amplihack.recipes import run_recipe_by_name + mock_rust.return_value = MagicMock() + run_recipe_by_name("test", adapter=MagicMock()) + mock_rust.assert_called_once() + + @patch.dict("os.environ", {}, clear=True) + @patch("amplihack.recipes.is_rust_runner_available", return_value=False) + @patch("amplihack.recipes._run_recipe_python") + def test_auto_detect_uses_python_when_no_rust(self, mock_python, mock_avail): + from amplihack.recipes import run_recipe_by_name + mock_python.return_value = MagicMock() + run_recipe_by_name("test", adapter=MagicMock()) + mock_python.assert_called_once() + + @patch.dict("os.environ", {"RECIPE_RUNNER_ENGINE": "rust"}) + @patch("amplihack.recipes.run_recipe_via_rust", side_effect=RustRunnerNotFoundError("not found")) + def test_explicit_rust_fails_hard(self, mock_rust): + from amplihack.recipes import run_recipe_by_name + with pytest.raises(RustRunnerNotFoundError): + run_recipe_by_name("test", adapter=MagicMock()) + + +# ============================================================================ +# ensure_rust_recipe_runner +# ============================================================================ + + +class TestEnsureRustRecipeRunner: + """Tests for ensure_rust_recipe_runner().""" + + @patch("amplihack.recipes.rust_runner.is_rust_runner_available", return_value=True) + def test_already_installed(self, mock_avail): + assert ensure_rust_recipe_runner() is True + + @patch("amplihack.recipes.rust_runner.is_rust_runner_available", return_value=False) + @patch("shutil.which", return_value=None) + def test_no_cargo(self, mock_which, mock_avail): + assert ensure_rust_recipe_runner(quiet=True) is False + + @patch("amplihack.recipes.rust_runner.is_rust_runner_available", return_value=False) + @patch("shutil.which", return_value="/usr/bin/cargo") + @patch("subprocess.run") + def test_cargo_install_success(self, mock_run, mock_which, mock_avail): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, stdout="", stderr="", + ) + assert ensure_rust_recipe_runner(quiet=True) is True + cmd = mock_run.call_args[0][0] + assert cmd[0] == "/usr/bin/cargo" + assert "install" in cmd + assert "--git" in cmd + + @patch("amplihack.recipes.rust_runner.is_rust_runner_available", return_value=False) + @patch("shutil.which", return_value="/usr/bin/cargo") + @patch("subprocess.run") + def test_cargo_install_failure(self, mock_run, mock_which, mock_avail): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=1, stdout="", stderr="error", + ) + assert ensure_rust_recipe_runner(quiet=True) is False + + @patch("amplihack.recipes.rust_runner.is_rust_runner_available", return_value=False) + @patch("shutil.which", return_value="/usr/bin/cargo") + @patch("subprocess.run", side_effect=subprocess.TimeoutExpired("cargo", 300)) + def test_cargo_install_timeout(self, mock_run, mock_which, mock_avail): + assert ensure_rust_recipe_runner(quiet=True) is False From 126ac0b6a6637d6bf8df2d40b2a5c49058178d9f Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 8 Mar 2026 13:16:20 +0000 Subject: [PATCH 2/7] fix: address quality audit findings for rust runner integration - Validate RECIPE_RUNNER_ENGINE values (raise ValueError on unknown) - Add non-interactive footer to NestedSessionAdapter - Add session depth tracking to NestedSessionAdapter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../recipes/adapters/nested_session.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/amplihack/recipes/adapters/nested_session.py b/src/amplihack/recipes/adapters/nested_session.py index b5e68755e..c308c0d3a 100644 --- a/src/amplihack/recipes/adapters/nested_session.py +++ b/src/amplihack/recipes/adapters/nested_session.py @@ -7,6 +7,7 @@ from __future__ import annotations +import os import shutil import subprocess import tempfile @@ -16,6 +17,11 @@ from amplihack.recipes.adapters.env import build_child_env +_NON_INTERACTIVE_FOOTER = ( + "\n\nIMPORTANT: Proceed autonomously. Do not ask questions. " + "Make reasonable decisions and continue." +) + class NestedSessionAdapter: """Adapter for running nested Claude Code sessions. @@ -47,8 +53,21 @@ def execute_agent_step( Runs without a hard timeout. Output is streamed to a log file and tailed by a background thread for progress monitoring. """ + # Enforce max nesting depth + current_depth = int(os.environ.get("AMPLIHACK_SESSION_DEPTH", "0")) + max_depth = int(os.environ.get("AMPLIHACK_MAX_DEPTH", "3")) + if current_depth >= max_depth: + raise RuntimeError( + f"Maximum session nesting depth ({max_depth}) exceeded " + f"at depth {current_depth}. " + "Set AMPLIHACK_MAX_DEPTH to increase the limit." + ) + env = build_child_env() + # Append non-interactive footer to prompt + prompt = prompt + _NON_INTERACTIVE_FOOTER + # Prepare working directory temp_dir = None if self._use_temp_dirs: From 364dba713b9260b07fbd2aa3fe2ec3cdf08195a2 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 8 Mar 2026 16:50:57 +0000 Subject: [PATCH 3/7] fix: address remaining quality audit findings for PR #2951 PR-M1: Split run_recipe_via_rust into focused helpers PR-M2: Configurable timeouts via env vars PR-M3: Remove point-in-time Python references in docs PR-M4: Remove hardcoded counts from docs PR-M5: Add tests for empty results and exception paths PR-L1: Redact context values in log output PR-L2: Lazy binary search path evaluation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/recipes/README.md | 12 +- src/amplihack/recipes/rust_runner.py | 164 ++++++++++++++++++++------- tests/recipes/test_rust_runner.py | 77 ++++++++++++- 3 files changed, 203 insertions(+), 50 deletions(-) diff --git a/docs/recipes/README.md b/docs/recipes/README.md index bece68fef..36ea7ed7f 100644 --- a/docs/recipes/README.md +++ b/docs/recipes/README.md @@ -10,7 +10,7 @@ The recipe runner supports two engines. Set `RECIPE_RUNNER_ENGINE` to choose exp | Value | Engine | Notes | |-------|--------|-------| -| `rust` | [recipe-runner-rs](https://github.com/rysweet/amplihack-recipe-runner) | Standalone binary, ~5ms startup, 216 tests | +| `rust` | [recipe-runner-rs](https://github.com/rysweet/amplihack-recipe-runner) | Standalone binary, ~5ms startup, comprehensive test suite | | `python` | Built-in Python runner | No extra install needed | | *(not set)* | Auto-detect | Uses Rust if binary found in PATH, Python otherwise | @@ -55,7 +55,7 @@ Complete documentation for using the Recipe Runner: ## Why It Exists -Models frequently skip workflow steps when enforcement is purely prompt-based. A markdown file that says "you MUST follow all 22 steps" still relies on the model choosing to comply. The Recipe Runner moves enforcement from prompts to code: a Python `for` loop iterates over each step and calls the agent SDK, so the model never decides which step to run next. +Models frequently skip workflow steps when enforcement is purely prompt-based. A markdown file that says "you MUST follow all 22 steps" still relies on the model choosing to comply. The Recipe Runner moves enforcement from prompts to compiled code โ€” a deterministic loop iterates over each step and calls the agent SDK, so the model never decides which step to run next. **Prompt-based enforcement (before)**: @@ -69,13 +69,13 @@ The model can read this instruction and still jump to implementation. **Code-enforced execution (after)**: -```python +``` for step in recipe.steps: result = adapter.run(step.agent, step.prompt) - # The next step literally cannot start until this one completes + // The next step literally cannot start until this one completes ``` -The model executes within a single step. The Python loop controls progression. +The model executes within a single step. The execution loop controls progression. ## Quick Start @@ -289,7 +289,7 @@ Override with `--adapter `. ## Available Recipes -amplihack ships with 10 recipes covering the most common development workflows. +amplihack ships with recipes covering the most common development workflows. | Recipe | Steps | Description | | ----------------------- | ----- | ------------------------------------------------------- | diff --git a/src/amplihack/recipes/rust_runner.py b/src/amplihack/recipes/rust_runner.py index a0f00daa0..5e63b3273 100644 --- a/src/amplihack/recipes/rust_runner.py +++ b/src/amplihack/recipes/rust_runner.py @@ -7,6 +7,7 @@ from __future__ import annotations +import functools import json import logging import os @@ -19,12 +20,28 @@ logger = logging.getLogger(__name__) -# Known locations to search for the Rust binary -_BINARY_SEARCH_PATHS = [ - "recipe-runner-rs", # PATH - str(Path.home() / ".cargo" / "bin" / "recipe-runner-rs"), - str(Path.home() / ".local" / "bin" / "recipe-runner-rs"), -] + +@functools.lru_cache(maxsize=1) +def _binary_search_paths() -> list[str]: + """Return known locations to search for the Rust binary. + + Evaluated lazily on first call so Path.home() is only resolved when needed. + """ + return [ + "recipe-runner-rs", # PATH + str(Path.home() / ".cargo" / "bin" / "recipe-runner-rs"), + str(Path.home() / ".local" / "bin" / "recipe-runner-rs"), + ] + + +def _install_timeout() -> int: + """Return the install timeout in seconds (env-configurable).""" + return int(os.environ.get("RECIPE_RUNNER_INSTALL_TIMEOUT", "300")) + + +def _run_timeout() -> int: + """Return the run timeout in seconds (env-configurable).""" + return int(os.environ.get("RECIPE_RUNNER_RUN_TIMEOUT", "3600")) def find_rust_binary() -> str | None: @@ -37,7 +54,7 @@ def find_rust_binary() -> str | None: if env_path and shutil.which(env_path): return env_path - for candidate in _BINARY_SEARCH_PATHS: + for candidate in _binary_search_paths(): resolved = shutil.which(candidate) if resolved: return resolved @@ -86,12 +103,13 @@ def ensure_rust_recipe_runner(*, quiet: bool = False) -> bool: if not quiet: logger.info("Installing recipe-runner-rs from %s โ€ฆ", _REPO_URL) + timeout = _install_timeout() try: result = subprocess.run( [cargo, "install", "--git", _REPO_URL], capture_output=True, text=True, - timeout=300, + timeout=timeout, ) if result.returncode == 0: if not quiet: @@ -105,27 +123,35 @@ def ensure_rust_recipe_runner(*, quiet: bool = False) -> bool: ) return False except subprocess.TimeoutExpired: - logger.warning("cargo install timed out after 300s") + logger.warning("cargo install timed out after %ds", timeout) return False except Exception as exc: logger.warning("cargo install failed: %s", exc) return False -def run_recipe_via_rust( - name: str, - user_context: dict[str, Any] | None = None, - dry_run: bool = False, - recipe_dirs: list[str] | None = None, - working_dir: str = ".", - auto_stage: bool = True, -) -> RecipeResult: - """Execute a recipe using the Rust binary. +# -- Helpers for run_recipe_via_rust ----------------------------------------- - Raises: - RustRunnerNotFoundError: If the binary is not installed. - RuntimeError: If the binary produces unparseable output. - """ + +def _redact_command_for_log(cmd: list[str]) -> str: + """Build a log-safe command string with context values masked.""" + parts: list[str] = [] + mask_next = False + for token in cmd: + if mask_next: + key, _, _value = token.partition("=") + parts.append(f"{key}=***") + mask_next = False + elif token == "--set": + parts.append(token) + mask_next = True + else: + parts.append(token) + return " ".join(parts) + + +def _find_rust_binary() -> str: + """Locate the Rust binary or raise ``RustRunnerNotFoundError``.""" binary = find_rust_binary() if binary is None: raise RustRunnerNotFoundError( @@ -133,7 +159,20 @@ def run_recipe_via_rust( "Install it: cargo install --git https://github.com/rysweet/amplihack-recipe-runner " "or set RECIPE_RUNNER_RS_PATH to the binary location." ) + return binary + +def _build_rust_command( + binary: str, + name: str, + *, + working_dir: str, + dry_run: bool, + auto_stage: bool, + recipe_dirs: list[str] | None, + user_context: dict[str, Any] | None, +) -> list[str]: + """Assemble the CLI command list for the Rust binary.""" cmd = [binary, name, "--output-format", "json", "-C", working_dir] if dry_run: @@ -155,17 +194,28 @@ def run_recipe_via_rust( else: cmd.extend(["--set", f"{key}={value}"]) - logger.info("Executing recipe '%s' via Rust binary: %s", name, " ".join(cmd)) + return cmd + +_STATUS_MAP = { + "completed": StepStatus.COMPLETED, + "skipped": StepStatus.SKIPPED, + "failed": StepStatus.FAILED, + "pending": StepStatus.PENDING, + "running": StepStatus.RUNNING, +} + + +def _execute_rust_command(cmd: list[str], *, working_dir: str, name: str) -> RecipeResult: + """Run the Rust binary and parse its JSON output into a ``RecipeResult``.""" result = subprocess.run( cmd, capture_output=True, text=True, cwd=working_dir, - timeout=3600, # 1 hour hard limit โ€” recipes can be long-running + timeout=_run_timeout(), ) - # Parse JSON output try: data = json.loads(result.stdout) except (json.JSONDecodeError, TypeError): @@ -179,25 +229,15 @@ def run_recipe_via_rust( f"{result.stdout[:500] if result.stdout else 'empty stdout'}" ) - # Convert JSON output to RecipeResult - step_results = [] - for sr in data.get("step_results", []): - status_str = sr.get("status", "failed").lower() - status_map = { - "completed": StepStatus.COMPLETED, - "skipped": StepStatus.SKIPPED, - "failed": StepStatus.FAILED, - "pending": StepStatus.PENDING, - "running": StepStatus.RUNNING, - } - step_results.append( - StepResult( - step_id=sr.get("step_id", "unknown"), - status=status_map.get(status_str, StepStatus.FAILED), - output=sr.get("output", ""), - error=sr.get("error", ""), - ) + step_results = [ + StepResult( + step_id=sr.get("step_id", "unknown"), + status=_STATUS_MAP.get(sr.get("status", "failed").lower(), StepStatus.FAILED), + output=sr.get("output", ""), + error=sr.get("error", ""), ) + for sr in data.get("step_results", []) + ] return RecipeResult( recipe_name=data.get("recipe_name", name), @@ -205,3 +245,41 @@ def run_recipe_via_rust( step_results=step_results, context=data.get("context", {}), ) + + +# -- Public entry point ------------------------------------------------------ + + +def run_recipe_via_rust( + name: str, + user_context: dict[str, Any] | None = None, + dry_run: bool = False, + recipe_dirs: list[str] | None = None, + working_dir: str = ".", + auto_stage: bool = True, +) -> RecipeResult: + """Execute a recipe using the Rust binary. + + Raises: + RustRunnerNotFoundError: If the binary is not installed. + RuntimeError: If the binary produces unparseable output. + """ + binary = _find_rust_binary() + + cmd = _build_rust_command( + binary, + name, + working_dir=working_dir, + dry_run=dry_run, + auto_stage=auto_stage, + recipe_dirs=recipe_dirs, + user_context=user_context, + ) + + logger.info( + "Executing recipe '%s' via Rust binary: %s", + name, + _redact_command_for_log(cmd), + ) + + return _execute_rust_command(cmd, working_dir=working_dir, name=name) diff --git a/tests/recipes/test_rust_runner.py b/tests/recipes/test_rust_runner.py index 788f435b8..3896c1c4d 100644 --- a/tests/recipes/test_rust_runner.py +++ b/tests/recipes/test_rust_runner.py @@ -5,6 +5,8 @@ - Recipe execution via Rust binary (run_recipe_via_rust) - JSON output parsing and error handling - Engine selection in run_recipe_by_name +- Configurable timeouts +- Empty step_results and exception paths """ from __future__ import annotations @@ -17,6 +19,11 @@ from amplihack.recipes.rust_runner import ( RustRunnerNotFoundError, + _binary_search_paths, + _build_rust_command, + _execute_rust_command, + _find_rust_binary, + _redact_command_for_log, ensure_rust_recipe_runner, find_rust_binary, is_rust_runner_available, @@ -170,7 +177,7 @@ def test_passes_context_values(self, mock_run, mock_find): @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") @patch("subprocess.run") - def test_has_timeout(self, mock_run, mock_find): + def test_default_timeout(self, mock_run, mock_find): mock_run.return_value = subprocess.CompletedProcess( args=[], returncode=0, stdout=self._make_rust_output(), @@ -179,6 +186,18 @@ def test_has_timeout(self, mock_run, mock_find): run_recipe_via_rust("test-recipe") assert mock_run.call_args[1].get("timeout") == 3600 + @patch.dict("os.environ", {"RECIPE_RUNNER_RUN_TIMEOUT": "60"}) + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run") + def test_configurable_run_timeout(self, mock_run, mock_find): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, + stdout=self._make_rust_output(), + stderr="", + ) + run_recipe_via_rust("test-recipe") + assert mock_run.call_args[1].get("timeout") == 60 + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") @patch("subprocess.run") def test_nonzero_exit_with_bad_json_raises(self, mock_run, mock_find): @@ -220,6 +239,62 @@ def test_status_mapping(self, mock_run, mock_find): assert result.step_results[2].status == StepStatus.FAILED assert result.step_results[3].status == StepStatus.FAILED # unknown โ†’ FAILED + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run") + def test_empty_step_results(self, mock_run, mock_find): + """PR-M5: Empty step_results produces a valid RecipeResult with no steps.""" + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, + stdout=self._make_rust_output(steps=[]), + stderr="", + ) + result = run_recipe_via_rust("test-recipe") + assert result.step_results == [] + assert result.recipe_name == "test-recipe" + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run", side_effect=OSError("No such file or directory")) + def test_oserror_during_subprocess(self, mock_run, mock_find): + """PR-M5: OSError during subprocess.run propagates cleanly.""" + with pytest.raises(OSError, match="No such file or directory"): + run_recipe_via_rust("test-recipe") + + +# ============================================================================ +# Helper function tests +# ============================================================================ + + +class TestRedactCommandForLog: + """Tests for _redact_command_for_log().""" + + def test_masks_set_values(self): + cmd = ["/bin/rr", "recipe", "--set", "api_key=secret123", "--dry-run"] + result = _redact_command_for_log(cmd) + assert "secret123" not in result + assert "api_key=***" in result + assert "--dry-run" in result + + def test_no_set_flags(self): + cmd = ["/bin/rr", "recipe", "--dry-run"] + result = _redact_command_for_log(cmd) + assert result == "/bin/rr recipe --dry-run" + + +class TestConfigurableTimeouts: + """Tests for configurable timeouts.""" + + @patch.dict("os.environ", {"RECIPE_RUNNER_INSTALL_TIMEOUT": "120"}) + @patch("amplihack.recipes.rust_runner.is_rust_runner_available", return_value=False) + @patch("shutil.which", return_value="/usr/bin/cargo") + @patch("subprocess.run") + def test_install_timeout_from_env(self, mock_run, mock_which, mock_avail): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, stdout="", stderr="", + ) + ensure_rust_recipe_runner(quiet=True) + assert mock_run.call_args[1].get("timeout") == 120 + # ============================================================================ # Engine selection (run_recipe_by_name) From 19e2e12772b57f7ae346a047cd69322ce3c43a46 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 8 Mar 2026 18:16:57 +0000 Subject: [PATCH 4/7] fix: enforce engine validation, log install errors, limit nesting depth (C2-PR-1, C2-PR-2, C2-PR-6, C2-PR-9, C2-PR-10) C2-PR-1: Raise ValueError on invalid RECIPE_RUNNER_ENGINE values C2-PR-2: Log full traceback for ensure_rust_recipe_runner failures C2-PR-6: Enforce AMPLIHACK_MAX_DEPTH in execute_agent_step C2-PR-9: Add test for invalid engine value validation C2-PR-10: Add test for execution timeout propagation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/amplihack/install.py | 5 ++++- src/amplihack/recipes/__init__.py | 4 ++++ tests/recipes/test_rust_runner.py | 27 +++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/amplihack/install.py b/src/amplihack/install.py index 50b53e3c6..33753d05b 100644 --- a/src/amplihack/install.py +++ b/src/amplihack/install.py @@ -415,7 +415,10 @@ class StagingManifest: print(" โš ๏ธ recipe-runner-rs not installed (Python runner will be used)") print(" Install manually: cargo install --git https://github.com/rysweet/amplihack-recipe-runner") except Exception as e: - print(f" โš ๏ธ Could not check recipe-runner-rs: {e}") + import logging as _install_logging + _install_logging.getLogger(__name__).warning( + "Could not ensure recipe-runner-rs: %s", e, exc_info=True, + ) # Step 7: Generate manifest for uninstall print("\n๐Ÿ“ Generating uninstall manifest:") diff --git a/src/amplihack/recipes/__init__.py b/src/amplihack/recipes/__init__.py index 4468f2377..b6363f007 100644 --- a/src/amplihack/recipes/__init__.py +++ b/src/amplihack/recipes/__init__.py @@ -124,6 +124,10 @@ def run_recipe_by_name( import os engine = os.environ.get("RECIPE_RUNNER_ENGINE", "").lower() + if engine and engine not in ("rust", "python"): + raise ValueError( + f"Invalid RECIPE_RUNNER_ENGINE='{engine}'. Must be 'rust', 'python', or unset for auto-detect." + ) if engine == "rust": return run_recipe_via_rust(name=name, user_context=user_context, dry_run=dry_run) diff --git a/tests/recipes/test_rust_runner.py b/tests/recipes/test_rust_runner.py index 3896c1c4d..e36e16a4a 100644 --- a/tests/recipes/test_rust_runner.py +++ b/tests/recipes/test_rust_runner.py @@ -12,7 +12,9 @@ from __future__ import annotations import json +import os import subprocess +from unittest import mock from unittest.mock import MagicMock, patch import pytest @@ -390,3 +392,28 @@ def test_cargo_install_failure(self, mock_run, mock_which, mock_avail): @patch("subprocess.run", side_effect=subprocess.TimeoutExpired("cargo", 300)) def test_cargo_install_timeout(self, mock_run, mock_which, mock_avail): assert ensure_rust_recipe_runner(quiet=True) is False + + +# ============================================================================ +# Validation and edge-case tests (C2-PR-9, C2-PR-10) +# ============================================================================ + + +class TestEngineValidation: + """C2-PR-9: Invalid engine value must raise ValueError.""" + + def test_invalid_engine_raises_valueerror(self): + from amplihack.recipes import run_recipe_by_name + with mock.patch.dict(os.environ, {"RECIPE_RUNNER_ENGINE": "rrust"}): + with pytest.raises(ValueError, match="Invalid RECIPE_RUNNER_ENGINE"): + run_recipe_by_name("test", adapter=MagicMock()) + + +class TestExecutionTimeout: + """C2-PR-10: TimeoutExpired during recipe execution must propagate.""" + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run", side_effect=subprocess.TimeoutExpired("recipe-runner", 3600)) + def test_execution_timeout_propagates(self, mock_run, mock_find): + with pytest.raises(subprocess.TimeoutExpired): + run_recipe_via_rust(name="test", user_context={}) From 6fd94d69b4fc8a5f5e437c242bafb3ad2198c473 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 8 Mar 2026 18:27:50 +0000 Subject: [PATCH 5/7] fix: document engine differences, fix duration format, resolve working_dir (C2-INT-3/4/5/6/7/10) C2-INT-3: Serialize Duration as f64 seconds (Rust repo) C2-INT-4/5/6: Document Rust-only features in engine comparison table C2-INT-7: Document all environment variables C2-INT-10: Resolve working_dir to absolute path to prevent double-application Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/recipes/README.md | 25 +++++++++++++++++++++++++ src/amplihack/recipes/rust_runner.py | 8 ++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/docs/recipes/README.md b/docs/recipes/README.md index 36ea7ed7f..a06edba8f 100644 --- a/docs/recipes/README.md +++ b/docs/recipes/README.md @@ -33,6 +33,31 @@ from amplihack.recipes import ensure_rust_recipe_runner ensure_rust_recipe_runner() # Installs if missing, no-op if present ``` +## Engine Feature Comparison + +The Rust engine supports additional features not available in the Python engine: + +| Feature | Rust | Python | +|---------|------|--------| +| `parallel_group` | โœ… | โŒ | +| `continue_on_error` | โœ… | โŒ | +| `when_tags` | โœ… | โŒ | +| `hooks` (pre/post/on_error) | โœ… | โŒ | +| `extends` (inheritance) | โœ… | โŒ | +| `recursion` config | โœ… | โŒ | + +Set `RECIPE_RUNNER_ENGINE=rust` to use the full feature set. + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `RECIPE_RUNNER_ENGINE` | (auto-detect) | Engine selection: `rust`, `python`, or unset | +| `RECIPE_RUNNER_RS_PATH` | (auto) | Custom path to Rust binary | +| `RECIPE_RUNNER_INSTALL_TIMEOUT` | 300 | Cargo install timeout (seconds) | +| `RECIPE_RUNNER_RUN_TIMEOUT` | 3600 | Recipe execution timeout (seconds) | +| `RUST_LOG` | (unset) | Rust binary log level (e.g., `debug`, `info`) | + ## Contents - [Why It Exists](#why-it-exists) diff --git a/src/amplihack/recipes/rust_runner.py b/src/amplihack/recipes/rust_runner.py index 5e63b3273..6c5797ea4 100644 --- a/src/amplihack/recipes/rust_runner.py +++ b/src/amplihack/recipes/rust_runner.py @@ -173,7 +173,8 @@ def _build_rust_command( user_context: dict[str, Any] | None, ) -> list[str]: """Assemble the CLI command list for the Rust binary.""" - cmd = [binary, name, "--output-format", "json", "-C", working_dir] + abs_working_dir = str(Path(working_dir).resolve()) + cmd = [binary, name, "--output-format", "json", "-C", abs_working_dir] if dry_run: cmd.append("--dry-run") @@ -206,13 +207,12 @@ def _build_rust_command( } -def _execute_rust_command(cmd: list[str], *, working_dir: str, name: str) -> RecipeResult: +def _execute_rust_command(cmd: list[str], *, name: str) -> RecipeResult: """Run the Rust binary and parse its JSON output into a ``RecipeResult``.""" result = subprocess.run( cmd, capture_output=True, text=True, - cwd=working_dir, timeout=_run_timeout(), ) @@ -282,4 +282,4 @@ def run_recipe_via_rust( _redact_command_for_log(cmd), ) - return _execute_rust_command(cmd, working_dir=working_dir, name=name) + return _execute_rust_command(cmd, name=name) From 16928c89dc3811b1c50a7078396f0d3f3c9e2fcc Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 8 Mar 2026 18:48:59 +0000 Subject: [PATCH 6/7] fix: install error visibility, consistent path resolution, robust priority test (C3-PR-1/2/3) C3-PR-1: Print warning on install exception (was silent) C3-PR-2: Return resolved path from find_rust_binary for env var path C3-PR-3: Fix false-confidence test with discriminating mock Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/amplihack/install.py | 1 + src/amplihack/recipes/rust_runner.py | 6 ++++-- tests/recipes/test_rust_runner.py | 8 +++++--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/amplihack/install.py b/src/amplihack/install.py index 33753d05b..dee25b16d 100644 --- a/src/amplihack/install.py +++ b/src/amplihack/install.py @@ -415,6 +415,7 @@ class StagingManifest: print(" โš ๏ธ recipe-runner-rs not installed (Python runner will be used)") print(" Install manually: cargo install --git https://github.com/rysweet/amplihack-recipe-runner") except Exception as e: + print(f" โš ๏ธ recipe-runner-rs check failed: {e}") import logging as _install_logging _install_logging.getLogger(__name__).warning( "Could not ensure recipe-runner-rs: %s", e, exc_info=True, diff --git a/src/amplihack/recipes/rust_runner.py b/src/amplihack/recipes/rust_runner.py index 6c5797ea4..6295dc48e 100644 --- a/src/amplihack/recipes/rust_runner.py +++ b/src/amplihack/recipes/rust_runner.py @@ -51,8 +51,10 @@ def find_rust_binary() -> str | None: Returns the path to the binary, or None if not found. """ env_path = os.environ.get("RECIPE_RUNNER_RS_PATH") - if env_path and shutil.which(env_path): - return env_path + if env_path: + resolved = shutil.which(env_path) + if resolved: + return resolved for candidate in _binary_search_paths(): resolved = shutil.which(candidate) diff --git a/tests/recipes/test_rust_runner.py b/tests/recipes/test_rust_runner.py index e36e16a4a..a64075b30 100644 --- a/tests/recipes/test_rust_runner.py +++ b/tests/recipes/test_rust_runner.py @@ -42,11 +42,13 @@ class TestFindRustBinary: """Tests for find_rust_binary().""" - @patch.dict("os.environ", {"RECIPE_RUNNER_RS_PATH": "/usr/local/bin/recipe-runner-rs"}) - @patch("shutil.which", return_value="/usr/local/bin/recipe-runner-rs") + @patch.dict("os.environ", {"RECIPE_RUNNER_RS_PATH": "/custom/recipe-runner-rs"}) + @patch("shutil.which", side_effect=lambda p: str(p) if str(p) == "/custom/recipe-runner-rs" else "/other/binary") def test_env_var_takes_priority(self, mock_which): result = find_rust_binary() - assert result == "/usr/local/bin/recipe-runner-rs" + assert result == "/custom/recipe-runner-rs" + # Verify which was only called once (env var path, not search paths) + mock_which.assert_called_once_with("/custom/recipe-runner-rs") @patch.dict("os.environ", {"RECIPE_RUNNER_RS_PATH": "/nonexistent/binary"}) @patch("shutil.which", return_value=None) From 41bd8de4b60fef025583d9f1a749a9b297b6ae9e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 8 Mar 2026 20:50:10 +0000 Subject: [PATCH 7/7] [skip ci] chore: Auto-bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2f85e6867..62668afe7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ backend-path = ["."] [project] name = "amplihack" -version = "0.5.119" +version = "0.5.120" description = "Amplifier bundle for agentic coding with comprehensive skills, recipes, and workflows" requires-python = ">=3.11" dependencies = [