diff --git a/README.md b/README.md index 0100928..8e55909 100644 --- a/README.md +++ b/README.md @@ -47,3 +47,46 @@ poetry run pytest # Run governor poetry run python -m agent.core.governor ``` + +## CLI Usage + +RAFT provides a command-line interface for managing the agent system: + +```bash +# Install RAFT (from this directory) +poetry install + +# Show help +raft --help + +# Run continuous governor loop with metrics server +raft run # Metrics on http://localhost:8002/metrics +raft run --metrics-port 9090 +raft run --cycle-interval 2.0 + +# Run a single cycle and get JSON status +raft one-cycle + +# Show version +raft version +``` + +### CLI Commands + +- **`raft run`**: Starts the continuous governor loop with Prometheus metrics server + - `--metrics-port`: Port for metrics server (default: 8002) + - `--cycle-interval`: Seconds between cycles (default: 1.0) + +- **`raft one-cycle`**: Executes exactly one governor cycle and outputs JSON: + ```json + { + "status": "success", + "rho": 0.456, + "energy": 1234567.89 + } + ``` + +- **`raft version`**: Displays the current RAFT version + +- **Global options**: + - `--verbose, -v`: Enable verbose logging diff --git a/agent/cli.py b/agent/cli.py new file mode 100644 index 0000000..1e00b08 --- /dev/null +++ b/agent/cli.py @@ -0,0 +1,142 @@ +"""RAFT CLI - Command line interface for the RAFT agent system. + +Provides subcommands for: +- run: Start continuous governor loop with metrics +- one-cycle: Run a single cycle and output JSON status +- version: Display RAFT version +""" + +import json +import time +import signal +import sys +from pathlib import Path + +import click +import toml +from loguru import logger +from prometheus_client import start_http_server + +from agent.core.governor import run_one_cycle +from agent.core.escape_hatches import is_paused +from agent.metrics import SPECTRAL_RHO, ENERGY_RATE + + +def get_version() -> str: + """Get RAFT version from pyproject.toml.""" + pyproject_path = Path(__file__).parents[1] / "pyproject.toml" + try: + with open(pyproject_path) as f: + data = toml.load(f) + return data["project"]["version"] + except (FileNotFoundError, KeyError): + return "unknown" + + +@click.group() +@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging") +def main(verbose: bool): + """RAFT - Recursive Agent for Formal Trust. + + A recursive agent system with formal trust guarantees, featuring + spectral radius guards, proof gates, and operator escape hatches. + """ + if verbose: + logger.remove() + logger.add(sys.stderr, level="DEBUG") + + +@main.command() +@click.option("--metrics-port", default=8002, help="Port for Prometheus metrics server") +@click.option("--cycle-interval", default=1.0, help="Seconds between cycles") +def run(metrics_port: int, cycle_interval: float): + """Start continuous governor loop with metrics server. + + Equivalent to the old metrics_server.py - runs cycles continuously + and exposes Prometheus metrics on the specified port. + """ + logger.info("Starting RAFT continuous governor with metrics on port {}", metrics_port) + + # Start Prometheus metrics server + start_http_server(metrics_port) + logger.info("Metrics server started on http://localhost:{}/metrics", metrics_port) + + # Setup signal handlers for graceful shutdown + shutdown_requested = False + + def signal_handler(signum, frame): + nonlocal shutdown_requested + logger.info("Shutdown signal received, stopping gracefully...") + shutdown_requested = True + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + cycles_completed = 0 + + try: + while not shutdown_requested and not is_paused(): + logger.debug("Starting governor cycle {}", cycles_completed + 1) + + success = run_one_cycle() + cycles_completed += 1 + + if success: + logger.debug("Cycle {} completed successfully", cycles_completed) + else: + logger.warning("Cycle {} failed or was aborted", cycles_completed) + + # Brief pause between cycles + time.sleep(cycle_interval) + + except KeyboardInterrupt: + logger.info("Keyboard interrupt received") + except Exception as e: + logger.error("Unexpected error in governor loop: {}", e) + sys.exit(1) + finally: + logger.info("Governor stopped after {} cycles", cycles_completed) + + +@main.command("one-cycle") +def one_cycle(): + """Run a single governor cycle and output JSON status. + + Executes exactly one run_one_cycle() and prints JSON result with: + - status: "success" or "failure" + - rho: current spectral radius value + - energy: current energy rate (J/s) + """ + logger.info("Running single governor cycle") + + # Run the cycle + success = run_one_cycle() + + # Collect metrics from the Prometheus gauges + # Note: These values are set during run_one_cycle() + rho_value = SPECTRAL_RHO._value._value if hasattr(SPECTRAL_RHO._value, '_value') else 0.0 + energy_value = ENERGY_RATE._value._value if hasattr(ENERGY_RATE._value, '_value') else 0.0 + + # Prepare JSON output + result = { + "status": "success" if success else "failure", + "rho": float(rho_value), + "energy": float(energy_value) + } + + # Output JSON to stdout + print(json.dumps(result, indent=2)) + + # Exit with appropriate code + sys.exit(0 if success else 1) + + +@main.command() +def version(): + """Display RAFT version information.""" + version_str = get_version() + click.echo(f"RAFT version {version_str}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d6cc4f5..1fa4486 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,9 +19,14 @@ dependencies = [ "gitpython (>=3.1.0,<4.0.0)", "fastapi (>=0.104.0,<1.0.0)", "uvicorn[standard] (>=0.24.0,<1.0.0)", - "prometheus-client (>=0.20.0,<1.0.0)" + "prometheus-client (>=0.20.0,<1.0.0)", + "click (>=8.0.0,<9.0.0)", + "toml (>=0.10.0,<1.0.0)" ] +[project.scripts] +raft = "agent.cli:main" + [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..0bda3bc --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,239 @@ +"""Tests for the RAFT CLI module. + +Tests all CLI commands using click.testing.CliRunner: +- raft run +- raft one-cycle +- raft version +""" + +import json +import time +from unittest.mock import patch, MagicMock + +import pytest +from click.testing import CliRunner + +from agent.cli import main, get_version + + +class TestCLIVersion: + """Test version command and version retrieval.""" + + def test_version_command(self): + """Test that version command outputs version string.""" + runner = CliRunner() + result = runner.invoke(main, ['version']) + + assert result.exit_code == 0 + assert "RAFT version" in result.output + assert "0.1.0" in result.output or "unknown" in result.output + + def test_get_version_function(self): + """Test get_version function directly.""" + version = get_version() + assert isinstance(version, str) + # Should be either actual version or "unknown" if file not found + assert version == "0.1.0" or version == "unknown" + + +class TestCLIOneCycle: + """Test one-cycle command.""" + + @patch('agent.cli.run_one_cycle') + def test_one_cycle_success(self, mock_run_one_cycle): + """Test one-cycle command with successful cycle.""" + # Setup mock + mock_run_one_cycle.return_value = True + + # Mock the Prometheus gauge values + with patch('agent.cli.SPECTRAL_RHO._value._value', 0.5), \ + patch('agent.cli.ENERGY_RATE._value._value', 1.23e6): + + runner = CliRunner() + result = runner.invoke(main, ['one-cycle']) + + assert result.exit_code == 0 + + # Parse JSON output + output_data = json.loads(result.output) + assert output_data["status"] == "success" + assert isinstance(output_data["rho"], float) + assert isinstance(output_data["energy"], float) + + @patch('agent.cli.run_one_cycle') + def test_one_cycle_failure(self, mock_run_one_cycle): + """Test one-cycle command with failed cycle.""" + # Setup mock + mock_run_one_cycle.return_value = False + + # Mock the Prometheus gauge values + with patch('agent.cli.SPECTRAL_RHO._value._value', 0.95), \ + patch('agent.cli.ENERGY_RATE._value._value', 0.0): + + runner = CliRunner() + result = runner.invoke(main, ['one-cycle']) + + assert result.exit_code == 1 # Should exit with error code + + # Parse JSON output + output_data = json.loads(result.output) + assert output_data["status"] == "failure" + assert isinstance(output_data["rho"], float) + assert isinstance(output_data["energy"], float) + + @patch('agent.cli.run_one_cycle') + def test_one_cycle_json_format(self, mock_run_one_cycle): + """Test that one-cycle outputs valid JSON with required fields.""" + mock_run_one_cycle.return_value = True + + # Mock gauge values to ensure they're accessible + with patch('agent.cli.SPECTRAL_RHO._value._value', 0.75), \ + patch('agent.cli.ENERGY_RATE._value._value', 9.87e5): + + runner = CliRunner() + result = runner.invoke(main, ['one-cycle']) + + # Should be valid JSON + output_data = json.loads(result.output) + + # Check required fields + assert "status" in output_data + assert "rho" in output_data + assert "energy" in output_data + + # Check types + assert output_data["status"] in ["success", "failure"] + assert isinstance(output_data["rho"], (int, float)) + assert isinstance(output_data["energy"], (int, float)) + + +class TestCLIRun: + """Test run command.""" + + @patch('agent.cli.start_http_server') + @patch('agent.cli.run_one_cycle') + @patch('agent.cli.is_paused') + @patch('agent.cli.time.sleep') + def test_run_command_basic(self, mock_sleep, mock_is_paused, mock_run_one_cycle, mock_start_server): + """Test run command starts metrics server and runs cycles.""" + # Setup mocks + mock_run_one_cycle.return_value = True + mock_is_paused.side_effect = [False, False, True] # Run 2 cycles then stop + + runner = CliRunner() + result = runner.invoke(main, ['run', '--cycle-interval', '0.1']) + + assert result.exit_code == 0 + + # Verify metrics server started + mock_start_server.assert_called_once_with(8002) # default port + + # Verify cycles were run + assert mock_run_one_cycle.call_count >= 2 + + # Verify sleep was called between cycles + mock_sleep.assert_called_with(0.1) + + @patch('agent.cli.start_http_server') + @patch('agent.cli.run_one_cycle') + @patch('agent.cli.is_paused') + @patch('agent.cli.time.sleep') + def test_run_command_custom_port(self, mock_sleep, mock_is_paused, mock_run_one_cycle, mock_start_server): + """Test run command with custom metrics port.""" + mock_run_one_cycle.return_value = True + mock_is_paused.return_value = True # Stop immediately + + runner = CliRunner() + result = runner.invoke(main, ['run', '--metrics-port', '9090']) + + assert result.exit_code == 0 + mock_start_server.assert_called_once_with(9090) + + @patch('agent.cli.start_http_server') + @patch('agent.cli.run_one_cycle') + @patch('agent.cli.is_paused') + def test_run_command_cycle_failure(self, mock_is_paused, mock_run_one_cycle, mock_start_server): + """Test run command handles cycle failures gracefully.""" + # Mix of successful and failed cycles + mock_run_one_cycle.side_effect = [True, False, True, False] + mock_is_paused.side_effect = [False, False, False, False, True] + + runner = CliRunner() + result = runner.invoke(main, ['run', '--cycle-interval', '0.01']) + + assert result.exit_code == 0 # Should not exit on cycle failures + assert mock_run_one_cycle.call_count >= 4 + + +class TestCLIMain: + """Test main CLI group and options.""" + + def test_main_help(self): + """Test main command shows help.""" + runner = CliRunner() + result = runner.invoke(main, ['--help']) + + assert result.exit_code == 0 + assert "RAFT - Recursive Agent for Formal Trust" in result.output + assert "run" in result.output + assert "one-cycle" in result.output + assert "version" in result.output + + def test_verbose_option(self): + """Test verbose option is accepted.""" + runner = CliRunner() + result = runner.invoke(main, ['--verbose', 'version']) + + assert result.exit_code == 0 + assert "RAFT version" in result.output + + def test_subcommand_help(self): + """Test subcommands have help text.""" + runner = CliRunner() + + # Test run command help + result = runner.invoke(main, ['run', '--help']) + assert result.exit_code == 0 + assert "continuous governor loop" in result.output + + # Test one-cycle command help + result = runner.invoke(main, ['one-cycle', '--help']) + assert result.exit_code == 0 + assert "single governor cycle" in result.output + + # Test version command help + result = runner.invoke(main, ['version', '--help']) + assert result.exit_code == 0 + assert "version information" in result.output + + +class TestCLIIntegration: + """Integration tests for CLI functionality.""" + + @patch('agent.cli.start_http_server') + @patch('agent.cli.is_paused') + def test_run_integration_with_real_cycle(self, mock_is_paused, mock_start_server): + """Test run command with actual run_one_cycle (no mocking).""" + # Only run one cycle to avoid long test times + mock_is_paused.side_effect = [False, True] + + runner = CliRunner() + result = runner.invoke(main, ['run', '--cycle-interval', '0.01']) + + # Should complete without errors (even if cycle fails due to test environment) + assert result.exit_code == 0 + mock_start_server.assert_called_once() + + def test_one_cycle_integration(self): + """Test one-cycle command with actual run_one_cycle (no mocking).""" + runner = CliRunner() + result = runner.invoke(main, ['one-cycle']) + + # Should produce valid JSON regardless of cycle success/failure + try: + output_data = json.loads(result.output) + assert "status" in output_data + assert "rho" in output_data + assert "energy" in output_data + except json.JSONDecodeError: + pytest.fail("Output is not valid JSON") \ No newline at end of file