Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,6 @@ eval_results.json
*_results/
eval_progressive_example/
generated-agents/

# Rust recipe runner repo checkout
amplihack-recipe-runner-rs/
68 changes: 62 additions & 6 deletions docs/recipes/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,62 @@
# 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, 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 |

```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
```

## 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

Expand All @@ -24,7 +80,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)**:

Expand All @@ -38,13 +94,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

Expand Down Expand Up @@ -258,7 +314,7 @@ Override with `--adapter <name>`.

## 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 |
| ----------------------- | ----- | ------------------------------------------------------- |
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
17 changes: 17 additions & 0 deletions src/amplihack/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,23 @@ 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" ⚠️ 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,
)

# Step 7: Generate manifest for uninstall
print("\n📝 Generating uninstall manifest:")

Expand Down
59 changes: 57 additions & 2 deletions src/amplihack/recipes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand Down Expand Up @@ -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",
Expand All @@ -47,18 +56,23 @@
"RecipeRunner",
"Recipe",
"RecipeResult",
"RustRunnerNotFoundError",
"Step",
"StepExecutionError",
"StepResult",
"StepStatus",
"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",
Expand Down Expand Up @@ -90,15 +104,56 @@ 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 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)

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")
Expand Down
19 changes: 19 additions & 0 deletions src/amplihack/recipes/adapters/nested_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from __future__ import annotations

import os
import shutil
import subprocess
import tempfile
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
Loading