diff --git a/README.md b/README.md index 60283bf..d8ba4d1 100644 --- a/README.md +++ b/README.md @@ -124,13 +124,13 @@ authority the change earned and proves it, a human merges. from umbra_core import resolve_available, get_executor # pick the first available agent (honoring a preference order) -agent = resolve_available(["claude-code", "codex-cli"]) +agent = resolve_available(["aider", "claude-code", "codex-cli"]) # or ask for one explicitly agent = get_executor("claude-code") result = agent.propose("bump the vulnerable dependency", repo_path=checkout) -print(result.executor) # "claude-code" | "codex-cli" | "unavailable" +print(result.executor) # "aider" | "claude-code" | "codex-cli" | "unavailable" print(result.diff) # recomputed from git on the final tree print(result.model_identity) # honest provenance for the receipt ``` @@ -139,6 +139,7 @@ Enable agents via environment flags (off by default, fail-closed): - `UMBRA_ENABLE_CODEX_CLI=true` (+ `codex login`) - `UMBRA_ENABLE_CLAUDE_CODE=true` (+ authenticated `claude` CLI) +- `UMBRA_ENABLE_AIDER=true` (+ an Aider-supported model provider) ## The admission pipeline diff --git a/tests/test_executors.py b/tests/test_executors.py index 2b8f255..6e183ba 100644 --- a/tests/test_executors.py +++ b/tests/test_executors.py @@ -11,6 +11,7 @@ import pytest from umbra_core import ( + AiderExecutor, ClaudeCodeExecutor, CodexExecutor, ExecutionResult, @@ -42,7 +43,7 @@ def __call__(self, command, *args, **kwargs): self.calls.append(list(command)) key = f"{command[0]}:{command[1] if len(command) > 1 else ''}" # Simulate the agent editing a file inside the checkout on the exec/print call. - if self._edit_file is not None and command[0] in {"codex", "claude"} and command[1] in {"exec", "-p"}: + if self._edit_file is not None and command[0] in {"aider", "codex", "claude"} and command[1] in {"--message", "exec", "-p"}: self._edit_file.write_text(self._edit_text) return self.responses.get(key, FakeCompleted()) @@ -61,12 +62,14 @@ def git_repo(tmp_path) -> Path: # --- protocol / registry ---------------------------------------------------- def test_both_executors_satisfy_protocol(): + assert isinstance(AiderExecutor(), Executor) assert isinstance(CodexExecutor(), Executor) assert isinstance(ClaudeCodeExecutor(), Executor) def test_registry_lists_and_resolves(): - assert set(available_executors()) == {"codex-cli", "claude-code", "none"} + assert set(available_executors()) == {"aider", "codex-cli", "claude-code", "none"} + assert isinstance(get_executor("aider"), AiderExecutor) assert isinstance(get_executor("codex-cli"), CodexExecutor) assert isinstance(get_executor("claude-code"), ClaudeCodeExecutor) assert isinstance(get_executor("none"), NullExecutor) @@ -78,6 +81,7 @@ def test_registry_unknown_raises(): def test_resolve_available_none_when_disabled(monkeypatch): + monkeypatch.delenv("UMBRA_ENABLE_AIDER", raising=False) monkeypatch.delenv("UMBRA_ENABLE_CODEX_CLI", raising=False) monkeypatch.delenv("UMBRA_ENABLE_CLAUDE_CODE", raising=False) # NullExecutor is always available but must NEVER be auto-selected. @@ -103,6 +107,16 @@ def test_codex_unavailable_without_flag(monkeypatch): assert CodexExecutor(runner=FakeRunner({})).available() is False +def test_aider_available_only_with_flag_and_cli(monkeypatch): + runner = FakeRunner({"aider:--version": FakeCompleted(stdout="aider 0.86.2")}) + monkeypatch.delenv("UMBRA_ENABLE_AIDER", raising=False) + assert AiderExecutor(runner=runner).available() is False + monkeypatch.setenv("UMBRA_ENABLE_AIDER", "true") + assert AiderExecutor(runner=FakeRunner({})).available() is False + assert AiderExecutor(runner=runner).available() is True + assert isinstance(resolve_available(["aider"], runner=runner), AiderExecutor) + + def test_codex_model_allowlist_native(monkeypatch): # Native provider: only the built-in allowlist is accepted; a gateway model is dropped. monkeypatch.delenv("OPENAI_BASE_URL", raising=False) @@ -188,6 +202,43 @@ def test_codex_propose_captures_diff(monkeypatch, git_repo): assert res.model_identity["executor"] == "codex-cli" +def test_aider_propose_captures_diff_without_commit_authority(monkeypatch, git_repo): + monkeypatch.setenv("UMBRA_ENABLE_AIDER", "true") + runner = FakeRunner( + { + "aider:--version": FakeCompleted(stdout="aider 0.86.2"), + "aider:--message": FakeCompleted(returncode=0, stdout="Bumped x to 2"), + }, + edit_file=git_repo / "app.py", + edit_text="x = 2\n", + ) + result = AiderExecutor(runner=runner, model="openrouter/example-model").propose( + "bump x", + git_repo, + ) + + assert result.executor == "aider" + assert "x = 2" in result.diff + assert result.model_identity["model_configured"] == "openrouter/example-model" + call = next(c for c in runner.calls if c[0] == "aider" and c[1] == "--message") + assert "--no-auto-commits" in call + assert "--no-suggest-shell-commands" in call + assert "bump x" not in " ".join(result.command or []) + + +def test_aider_read_only_uses_dry_run(monkeypatch, git_repo): + monkeypatch.setenv("UMBRA_ENABLE_AIDER", "true") + runner = FakeRunner( + { + "aider:--version": FakeCompleted(stdout="aider 0.86.2"), + "aider:--message": FakeCompleted(returncode=0, stdout="analysis"), + } + ) + AiderExecutor(runner=runner).propose("review", git_repo, read_only=True) + call = next(c for c in runner.calls if c[0] == "aider" and c[1] == "--message") + assert "--dry-run" in call + + def test_claude_propose_captures_diff_and_parses_json(monkeypatch, git_repo): monkeypatch.setenv("UMBRA_ENABLE_CLAUDE_CODE", "true") runner = FakeRunner( diff --git a/umbra_core/__init__.py b/umbra_core/__init__.py index 696de6f..713634f 100644 --- a/umbra_core/__init__.py +++ b/umbra_core/__init__.py @@ -1,4 +1,5 @@ """umbra-core — an agent-agnostic change-control plane for coding agents.""" +from .executors.aider import AiderExecutor from .executors.base import ExecutionResult, Executor from .executors.claude_code import ClaudeCodeExecutor from .executors.codex import CodexExecutor @@ -71,6 +72,7 @@ # executors "Executor", "ExecutionResult", + "AiderExecutor", "CodexExecutor", "ClaudeCodeExecutor", "NullExecutor", diff --git a/umbra_core/cli.py b/umbra_core/cli.py index a6bdc05..9aaf9a5 100644 --- a/umbra_core/cli.py +++ b/umbra_core/cli.py @@ -73,7 +73,8 @@ def cmd_admit(args: argparse.Namespace) -> int: if not executor.available(): print( f"error: agent {args.agent!r} is not available. Enable + authenticate it " - f"(e.g. UMBRA_ENABLE_CLAUDE_CODE=true / UMBRA_ENABLE_CODEX_CLI=true).", + "(e.g. UMBRA_ENABLE_AIDER=true / UMBRA_ENABLE_CLAUDE_CODE=true / " + "UMBRA_ENABLE_CODEX_CLI=true).", file=sys.stderr, ) return 2 @@ -82,7 +83,8 @@ def cmd_admit(args: argparse.Namespace) -> int: if executor is None: print( "error: no coding agent is available. Enable one with " - "UMBRA_ENABLE_CLAUDE_CODE=true or UMBRA_ENABLE_CODEX_CLI=true, " + "UMBRA_ENABLE_AIDER=true, UMBRA_ENABLE_CLAUDE_CODE=true, or " + "UMBRA_ENABLE_CODEX_CLI=true, " "or pass --agent.", file=sys.stderr, ) diff --git a/umbra_core/executors/__init__.py b/umbra_core/executors/__init__.py index 06b7312..bf35a7e 100644 --- a/umbra_core/executors/__init__.py +++ b/umbra_core/executors/__init__.py @@ -1,3 +1,4 @@ +from .aider import AiderExecutor from .base import ExecutionResult, Executor from .claude_code import ClaudeCodeExecutor from .codex import CodexExecutor @@ -7,6 +8,7 @@ __all__ = [ "Executor", "ExecutionResult", + "AiderExecutor", "CodexExecutor", "ClaudeCodeExecutor", "NullExecutor", diff --git a/umbra_core/executors/aider.py b/umbra_core/executors/aider.py new file mode 100644 index 0000000..5edeefe --- /dev/null +++ b/umbra_core/executors/aider.py @@ -0,0 +1,140 @@ +"""Aider CLI executor adapted to Umbra's agent-neutral protocol.""" +from __future__ import annotations + +import os +import subprocess +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from ._shared import ( + Runner, + bounded_prompt, + changed_files, + reason_prompt, + sanitize_paths, + unified_diff, +) +from .base import ExecutionResult + + +class AiderExecutor: + """Draft changes with Aider while withholding commit and push authority.""" + + name = "aider" + + def __init__( + self, + runner: Runner = subprocess.run, + model: str | None = None, + ) -> None: + self.runner = runner + configured = model if model is not None else os.getenv("UMBRA_AIDER_MODEL") + self.model = (configured or "").strip() or None + + def available(self) -> bool: + if os.getenv("UMBRA_ENABLE_AIDER", "false").lower() != "true": + return False + return self._cli_version() is not None + + def _cli_version(self) -> str | None: + try: + result = self.runner( + ["aider", "--version"], + text=True, + capture_output=True, + timeout=15, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + if result is None: + return None + output = ( + (getattr(result, "stdout", "") or "") + + (getattr(result, "stderr", "") or "") + ).strip() + return output.splitlines()[0].strip() if output else None + + def model_identity(self) -> dict[str, Any]: + return { + "executor": self.name, + "cli_version": self._cli_version() or "unavailable", + "model_configured": self.model or "aider-default", + "model_resolved": "unavailable", + "model_evidence": "cli-argument" if self.model else "aider-default", + } + + def propose(self, prompt: str, repo_path: Path, *, read_only: bool = False) -> ExecutionResult: + if not self.available(): + return ExecutionResult.disabled( + prompt, + self.name, + "Aider is disabled. Set UMBRA_ENABLE_AIDER=true and configure an Aider model provider.", + ) + if repo_path is None or not repo_path.is_dir(): + raise RuntimeError("A checked-out repository is required for AiderExecutor.propose()") + + cli_prompt = reason_prompt(prompt, "Aider") if read_only else bounded_prompt(prompt, "Aider") + command = [ + "aider", + "--message", + cli_prompt, + "--yes-always", + "--no-auto-commits", + "--no-dirty-commits", + "--no-gitignore", + "--no-suggest-shell-commands", + ] + if read_only: + command.append("--dry-run") + if self.model: + command.extend(["--model", self.model]) + + redacted_command = ( + command[:2] + + [""] + + command[3:] + ) + try: + completed = self.runner( + command, + text=True, + capture_output=True, + timeout=900, + check=False, + cwd=str(repo_path), + ) + except (OSError, subprocess.SubprocessError) as exc: + return ExecutionResult.failed( + prompt, + self.name, + str(exc)[:300], + command=redacted_command, + ) + + returncode = getattr(completed, "returncode", 1) + stdout = getattr(completed, "stdout", "") or "" + stderr = getattr(completed, "stderr", "") or "" + diff = unified_diff(repo_path) + files = changed_files(repo_path) + summary = stdout.strip() + if not summary: + if returncode == 0: + summary = "Aider completed; see the diff below." if diff else "Aider ran and produced no changes." + else: + summary = f"Aider failed (exit {returncode})." + + return ExecutionResult( + prompt=prompt, + summary=sanitize_paths(summary, repo_path), + diff=diff, + tests_passed=returncode == 0, + files=files, + executor=self.name if returncode == 0 else "unavailable", + created_at=datetime.now(UTC).isoformat(), + command=redacted_command, + stdout=sanitize_paths(stdout[-12000:], repo_path), + error=sanitize_paths(stderr[-4000:], repo_path) or None, + model_identity=self.model_identity(), + ) diff --git a/umbra_core/executors/registry.py b/umbra_core/executors/registry.py index 72beec9..8d5c1d8 100644 --- a/umbra_core/executors/registry.py +++ b/umbra_core/executors/registry.py @@ -9,6 +9,7 @@ import subprocess from typing import Callable +from .aider import AiderExecutor from .base import Executor from .claude_code import ClaudeCodeExecutor from .codex import CodexExecutor @@ -18,6 +19,7 @@ # name -> factory(runner) -> Executor _REGISTRY: dict[str, Callable[[Runner], Executor]] = { + "aider": lambda runner: AiderExecutor(runner=runner), "codex-cli": lambda runner: CodexExecutor(runner=runner), "claude-code": lambda runner: ClaudeCodeExecutor(runner=runner), "none": lambda runner: NullExecutor(), # govern an existing working-tree change