Skip to content
Closed
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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

Expand Down
55 changes: 53 additions & 2 deletions tests/test_executors.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import pytest

from umbra_core import (
AiderExecutor,
ClaudeCodeExecutor,
CodexExecutor,
ExecutionResult,
Expand Down Expand Up @@ -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())

Expand All @@ -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)
Expand All @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions umbra_core/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -71,6 +72,7 @@
# executors
"Executor",
"ExecutionResult",
"AiderExecutor",
"CodexExecutor",
"ClaudeCodeExecutor",
"NullExecutor",
Expand Down
6 changes: 4 additions & 2 deletions umbra_core/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
Expand Down
2 changes: 2 additions & 0 deletions umbra_core/executors/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .aider import AiderExecutor
from .base import ExecutionResult, Executor
from .claude_code import ClaudeCodeExecutor
from .codex import CodexExecutor
Expand All @@ -7,6 +8,7 @@
__all__ = [
"Executor",
"ExecutionResult",
"AiderExecutor",
"CodexExecutor",
"ClaudeCodeExecutor",
"NullExecutor",
Expand Down
140 changes: 140 additions & 0 deletions umbra_core/executors/aider.py
Original file line number Diff line number Diff line change
@@ -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]
+ ["<agent prompt redacted from command replay>"]
+ 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(),
)
2 changes: 2 additions & 0 deletions umbra_core/executors/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down