diff --git a/.github/workflows/adapters.yml b/.github/workflows/adapters.yml new file mode 100644 index 0000000..2082c75 --- /dev/null +++ b/.github/workflows/adapters.yml @@ -0,0 +1,154 @@ +name: Avo Adapter Matrix + +on: + push: + branches: [main] + pull_request: + +jobs: + adapter-discovery: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - extra: codex + adapter: nvidia.fabric.codex + - extra: claude + adapter: nvidia.fabric.claude + - extra: deepagents + adapter: nvidia.fabric.langchain.deepagents + - extra: mini-swe-agent + adapter: nvidia.fabric.mini-swe-agent + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: pyproject.toml + - name: Create isolated environment + run: python -m venv "$RUNNER_TEMP/avo-adapter" + - name: Install Avo and adapter + env: + AVO_ADAPTER_EXTRA: ${{ matrix.extra }} + run: | + "$RUNNER_TEMP/avo-adapter/bin/python" -m pip install ".[${AVO_ADAPTER_EXTRA}]" + "$RUNNER_TEMP/avo-adapter/bin/python" -m pip check + - name: Resolve and plan installed adapter + env: + AVO_ADAPTER_ID: ${{ matrix.adapter }} + run: | + cd "$RUNNER_TEMP" + "$RUNNER_TEMP/avo-adapter/bin/python" - <<'PY' + import os + import sys + from pathlib import Path + + from nemo_fabric import Fabric + + from avo_harness.config import WorkerConfig + from avo_harness.worker import NeMoWorker, _adapter_python_env + + adapter_id = os.environ["AVO_ADAPTER_ID"] + workspace = Path(os.environ["GITHUB_WORKSPACE"]) + config = WorkerConfig( + backend="nemo", + adapter_id=adapter_id, + provider="openai", + model="smoke", + api_key_env="OPENAI_API_KEY", + base_url="http://127.0.0.1:1/v1", + env={"OPENAI_API_KEY": "smoke"}, + timeout_seconds=5, + ) + worker = NeMoWorker(config) + with _adapter_python_env(sys.executable): + fabric_config = worker._fabric_config(workspace) + plan = Fabric().plan(fabric_config, base_dir=workspace) + resolved = getattr(getattr(plan, "adapter", None), "adapter_id", None) + assert resolved == adapter_id, (resolved, adapter_id) + print(f"resolved {adapter_id} with {sys.executable}") + PY + + isolated-hermes: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: pyproject.toml + - name: Create separate Avo and Hermes environments + run: | + python -m venv "$RUNNER_TEMP/avo-main" + python -m venv "$RUNNER_TEMP/hermes-adapter" + - name: Install Avo runtime without the Hermes adapter + run: | + "$RUNNER_TEMP/avo-main/bin/python" -m pip install '.[fabric]' + "$RUNNER_TEMP/avo-main/bin/python" -m pip check + - name: Install Hermes only in the adapter environment + run: | + "$RUNNER_TEMP/hermes-adapter/bin/python" -m pip install 'nemo-fabric[hermes-agent]==0.2.0' + "$RUNNER_TEMP/hermes-adapter/bin/python" scripts/install_hermes.py "$RUNNER_TEMP/hermes-agent" + "$RUNNER_TEMP/hermes-adapter/bin/python" -m pip check + - name: Run Hermes through ADAPTER_PYTHON + run: | + "$RUNNER_TEMP/avo-main/bin/python" \ + "$GITHUB_WORKSPACE/tests/support/openai_smoke_server.py" 18080 \ + >"$RUNNER_TEMP/isolated-hermes-smoke.log" 2>&1 & + fixture_pid=$! + cleanup() { + kill "$fixture_pid" 2>/dev/null || true + } + trap cleanup EXIT + + ready=0 + for _ in $(seq 1 20); do + if curl -fsS http://127.0.0.1:18080/v1/models >/dev/null; then + ready=1 + break + fi + sleep 0.25 + done + if [ "$ready" -ne 1 ]; then + cat "$RUNNER_TEMP/isolated-hermes-smoke.log" + exit 1 + fi + + cd "$RUNNER_TEMP" + "$RUNNER_TEMP/avo-main/bin/python" - <<'PY' + import os + from pathlib import Path + + from avo_harness.config import WorkerConfig + from avo_harness.worker import NeMoWorker + + workspace = Path(os.environ["GITHUB_WORKSPACE"]) + adapter_python = str(Path(os.environ["RUNNER_TEMP"], "hermes-adapter", "bin", "python")) + worker = NeMoWorker( + WorkerConfig( + backend="nemo", + adapter_id="nvidia.fabric.hermes", + provider="openai", + model="local-open-model", + base_url="http://127.0.0.1:18080/v1", + env={ + "ADAPTER_PYTHON": adapter_python, + "OPENAI_API_KEY": "smoke", + }, + max_turns=1, + timeout_seconds=30, + system_prompt="Do not use tools. Return the model response directly.", + ) + ) + preflight = worker.validate(workspace) + assert preflight.success, preflight.error + assert preflight.metadata["adapter_python"] == adapter_python + result = worker.run("Reply exactly AVO_HERMES_SMOKE_OK", workspace) + assert result.success, result.error + assert result.metadata["adapter_python"] == adapter_python + assert "AVO_HERMES_SMOKE_OK" in result.output, result.output + print(result.output) + PY diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a1182c..c121df0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,14 +2,15 @@ name: AVO Harness on: push: + branches: [main] pull_request: jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: python-version: "3.12" cache: pip @@ -19,31 +20,304 @@ jobs: - name: Test run: python -m pytest -q + install-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: pyproject.toml + - name: Create isolated environment + run: python -m venv "$RUNNER_TEMP/avo-install" + - name: Install pinned Hermes Agent source + run: | + "$RUNNER_TEMP/avo-install/bin/python" scripts/install_hermes.py "$RUNNER_TEMP/hermes-agent" + - name: Install Avo as a package + run: | + cd "$GITHUB_WORKSPACE" + "$RUNNER_TEMP/avo-install/bin/python" -m pip install '.[web,nemo]' + "$RUNNER_TEMP/avo-install/bin/python" -m pip check + - name: Verify installed CLI and default adapter preflight + run: | + cd "$RUNNER_TEMP" + "$RUNNER_TEMP/avo-install/bin/avo-harness" --help >/dev/null + "$RUNNER_TEMP/avo-install/bin/avo-harness" init \ + --repo "$GITHUB_WORKSPACE" \ + --state-dir "$RUNNER_TEMP/avo-state" \ + --output "$RUNNER_TEMP/avo.json" + OPENAI_API_KEY=local \ + "$RUNNER_TEMP/avo-install/bin/avo-harness" doctor -c "$RUNNER_TEMP/avo.json" + - name: Execute real NeMo Hermes turn + run: | + "$RUNNER_TEMP/avo-install/bin/python" \ + "$GITHUB_WORKSPACE/tests/support/openai_smoke_server.py" 18080 \ + >"$RUNNER_TEMP/openai-smoke.log" 2>&1 & + fixture_pid=$! + cleanup() { + kill "$fixture_pid" 2>/dev/null || true + } + trap cleanup EXIT + + ready=0 + for _ in $(seq 1 20); do + if curl -fsS http://127.0.0.1:18080/v1/models >/dev/null; then + ready=1 + break + fi + sleep 0.25 + done + if [ "$ready" -ne 1 ]; then + cat "$RUNNER_TEMP/openai-smoke.log" + exit 1 + fi + + cd "$RUNNER_TEMP" + OPENAI_API_KEY=smoke "$RUNNER_TEMP/avo-install/bin/python" - <<'PY' + import os + from pathlib import Path + + from avo_harness.config import WorkerConfig + from avo_harness.worker import NeMoWorker + + workspace = Path(os.environ["GITHUB_WORKSPACE"]) + worker = NeMoWorker( + WorkerConfig( + backend="nemo", + adapter_id="nvidia.fabric.hermes", + provider="openai", + model="local-open-model", + base_url="http://127.0.0.1:18080/v1", + env={"OPENAI_API_KEY": "smoke"}, + max_turns=1, + timeout_seconds=30, + system_prompt=( + "This is an installation smoke test. Do not use tools. " + "Return the model response directly." + ), + ) + ) + preflight = worker.validate(workspace) + assert preflight.success, preflight.error + result = worker.run("Reply exactly AVO_HERMES_SMOKE_OK", workspace) + assert result.success, result.error + assert "AVO_HERMES_SMOKE_OK" in result.output, result.output + print(result.output) + PY + - name: Boot installed API and complete a chat message + run: | + "$RUNNER_TEMP/avo-install/bin/python" \ + "$GITHUB_WORKSPACE/tests/support/openai_smoke_server.py" 18080 \ + >"$RUNNER_TEMP/openai-chat-smoke.log" 2>&1 & + fixture_pid=$! + + "$RUNNER_TEMP/avo-install/bin/python" - <<'PY' + import json + import os + from pathlib import Path + + path = Path(os.environ["RUNNER_TEMP"], "avo.json") + config = json.loads(path.read_text()) + config["worker"]["provider"] = "openai" + config["worker"]["model"] = "local-open-model" + config["worker"]["base_url"] = "http://127.0.0.1:18080/v1" + config["worker"]["max_turns"] = 1 + config["worker"]["timeout_seconds"] = 30 + path.write_text(json.dumps(config, indent=2) + "\n") + PY + + cd "$RUNNER_TEMP" + OPENAI_API_KEY=smoke \ + AVO_AUTH_DISABLED=1 \ + AVO_PUBLIC_ORIGIN=http://127.0.0.1:8765 \ + "$RUNNER_TEMP/avo-install/bin/avo-harness" web \ + -c "$RUNNER_TEMP/avo.json" \ + --host 127.0.0.1 \ + --port 8765 \ + >"$RUNNER_TEMP/avo-web.log" 2>&1 & + server_pid=$! + cleanup() { + kill "$server_pid" 2>/dev/null || true + kill "$fixture_pid" 2>/dev/null || true + } + trap cleanup EXIT + + healthy=0 + for _ in $(seq 1 30); do + if curl -fsS http://127.0.0.1:8765/api/health >"$RUNNER_TEMP/health.json"; then + healthy=1 + break + fi + sleep 1 + done + if [ "$healthy" -ne 1 ]; then + cat "$RUNNER_TEMP/avo-web.log" + cat "$RUNNER_TEMP/openai-chat-smoke.log" + exit 1 + fi + + "$RUNNER_TEMP/avo-install/bin/python" - <<'PY' + import json + import os + import time + import urllib.request + from pathlib import Path + + root = Path(os.environ["RUNNER_TEMP"]) + health = json.loads((root / "health.json").read_text()) + assert health["ok"] is True + assert Path(health["state_dir"]) == root / "avo-state" + + with urllib.request.urlopen("http://127.0.0.1:8765/api/chat/conversations") as response: + conversations = json.load(response) + assert isinstance(conversations, list) + for conversation in conversations: + assert {"id", "title", "created_at", "updated_at", "message_count"} <= set(conversation) + + request = urllib.request.Request( + "http://127.0.0.1:8765/api/chat/messages", + data=json.dumps( + {"content": "Return a short installation smoke-test answer.", "auto_execute": False} + ).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request) as response: + accepted = json.load(response) + assert accepted["accepted"] is True, accepted + assert accepted["conversation_id"] == "main", accepted + + for _ in range(60): + with urllib.request.urlopen("http://127.0.0.1:8765/api/chat/messages?limit=100") as response: + messages = json.load(response) + assistants = [item for item in messages if item.get("role") == "assistant"] + if assistants: + latest = assistants[-1] + if latest.get("status") == "error": + raise AssertionError(latest) + if latest.get("status") == "complete": + assert "AVO_HERMES_SMOKE_OK" in latest.get("content", ""), latest + break + time.sleep(0.5) + else: + raise AssertionError(f"assistant message did not complete: {messages!r}") + PY + ui: runs-on: ubuntu-latest defaults: run: working-directory: ui steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: "22.13" cache: npm cache-dependency-path: ui/package.json - name: Install run: npm install + - name: Resolved direct dependencies + run: npm ls --depth=0 - name: Typecheck run: npm run typecheck + - name: Expo dependency compatibility + run: npx expo install --check - name: Export web run: npx expo export --platform web image: runs-on: ubuntu-latest - needs: [test, ui] + needs: [test, install-smoke, ui] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Build unified web/API image run: docker build --tag avo-code-ci . - - name: Verify NeMo Fabric runtime - run: docker run --rm --entrypoint python avo-code-ci -c "import nemo_fabric; print('NeMo Fabric available')" + - name: Verify packaged runtime + run: | + docker run --rm --entrypoint python avo-code-ci -m pip check + docker run --rm --entrypoint avo-harness avo-code-ci --help >/dev/null + - name: Boot production image and exercise UI/API + run: | + smoke="$RUNNER_TEMP/avo-image-smoke" + mkdir -p "$smoke/data" "$smoke/repo" + git -C "$smoke/repo" init + git -C "$smoke/repo" config user.email ci@example.invalid + git -C "$smoke/repo" config user.name "Avo CI" + printf 'smoke\n' >"$smoke/repo/README.md" + git -C "$smoke/repo" add README.md + git -C "$smoke/repo" commit -m init + + docker run --rm \ + --entrypoint avo-harness \ + -v "$smoke/data:/data" \ + -v "$smoke/repo:/workspace" \ + avo-code-ci \ + init --repo /workspace --state-dir /data/state --output /data/avo.json + + docker run --rm \ + --entrypoint avo-harness \ + -e OPENAI_API_KEY=local \ + -v "$smoke/data:/data" \ + -v "$smoke/repo:/workspace" \ + avo-code-ci \ + doctor -c /data/avo.json + + docker run -d \ + --name avo-code-smoke \ + -p 127.0.0.1:8765:8765 \ + -v "$smoke/data:/data" \ + -v "$smoke/repo:/workspace" \ + -e OPENAI_API_KEY=local \ + -e AVO_AUTH_DISABLED=1 \ + -e AVO_PUBLIC_ORIGIN=http://127.0.0.1:8765 \ + avo-code-ci + + cleanup() { + docker rm -f avo-code-smoke >/dev/null 2>&1 || true + } + trap cleanup EXIT + + healthy=0 + for _ in $(seq 1 30); do + if curl -fsS http://127.0.0.1:8765/api/health >"$smoke/health.json"; then + healthy=1 + break + fi + sleep 1 + done + if [ "$healthy" -ne 1 ]; then + docker logs avo-code-smoke + exit 1 + fi + + python - <<'PY' + import json + import os + from pathlib import Path + + smoke = Path(os.environ["RUNNER_TEMP"], "avo-image-smoke") + health = json.loads((smoke / "health.json").read_text()) + assert health["ok"] is True + assert health["state_dir"] == "/data/state" + assert health["static_web"] is True + PY + curl -fsS http://127.0.0.1:8765/api/chat/conversations >"$smoke/conversations.json" + python - <<'PY' + import json + import os + from pathlib import Path + + smoke = Path(os.environ["RUNNER_TEMP"], "avo-image-smoke") + conversations = json.loads((smoke / "conversations.json").read_text()) + assert isinstance(conversations, list) + for conversation in conversations: + assert {"id", "title", "created_at", "updated_at", "message_count"} <= set(conversation) + PY + curl -fsS http://127.0.0.1:8765/ >"$smoke/index.html" + grep -Eiq '/dev/null + trap - EXIT + test -f "$smoke/data/state/state.sqlite3" diff --git a/.gitignore b/.gitignore index 781a674..8339540 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .venv/ +.deps/ __pycache__/ *.py[cod] .pytest_cache/ diff --git a/Dockerfile b/Dockerfile index d95abaa..d4b04b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,40 +13,39 @@ RUN apt-get update \ WORKDIR /app COPY pyproject.toml README.md ./ COPY src ./src -# The production image ships the default Hermes stack only. NeMo Fabric supports -# separate adapter environments through ADAPTER_PYTHON, which avoids dependency -# collisions between harnesses (for example Hermes' OpenAI pin vs Deep Agents). -# Hermes Agent 0.20+ is source-distributed and deliberately refuses wheel/sdist -# builds, so keep a pinned checkout in the image and install it editable. -RUN git clone --depth 1 --branch "${HERMES_AGENT_REF}" \ - https://github.com/NousResearch/hermes-agent.git /opt/hermes-agent \ - && python -m pip install --no-cache-dir -e /opt/hermes-agent \ +COPY scripts ./scripts +# The production image ships the default Hermes stack only. Keep the source +# checkout/install recipe shared with local development and CI so an environment +# cannot look healthy merely because the NeMo runtime itself imports. +RUN PIP_NO_CACHE_DIR=1 HERMES_AGENT_REF="${HERMES_AGENT_REF}" \ + python scripts/install_hermes.py /opt/hermes-agent \ && python -m pip install --no-cache-dir '.[web,nemo]' \ && python -m pip check -# Importing nemo_fabric alone does not prove the configured descriptor exists. -# Planning resolves the default adapter and normalized config without contacting -# a model, so an UnknownAdapter regression fails the image build. +# Exercise Avo's own no-model preflight. This resolves the configured descriptor, +# runs Fabric.doctor, and checks the credential environment Hermes will require +# before the first actual turn. The dummy key is valid only for this unreachable +# unauthenticated smoke endpoint. RUN python - <<'PY' -from nemo_fabric import Fabric, FabricConfig +from pathlib import Path + +from avo_harness.config import WorkerConfig +from avo_harness.worker import NeMoWorker adapter_id = "nvidia.fabric.hermes" -payload = { - "metadata": {"name": "avo-image-smoke-hermes"}, - "harness": {"adapter_id": adapter_id, "settings": {}}, - "runtime": {"max_turns": 1, "timeout_seconds": 1}, - "environment": {"provider": "local", "workspace": "/tmp", "env": {}}, - "models": { - "default": { - "provider": "openai", - "model": "smoke", - "base_url": "http://127.0.0.1:1/v1", - } - }, -} -config = FabricConfig.from_mapping(payload) if hasattr(FabricConfig, "from_mapping") else FabricConfig(**payload) -plan = Fabric().plan(config) -assert plan.adapter.adapter_id == adapter_id, plan -print(f"NeMo Fabric adapter available: {adapter_id}") +worker = WorkerConfig( + backend="nemo", + adapter_id=adapter_id, + model="smoke", + provider="openai", + base_url="http://127.0.0.1:1/v1", + env={"OPENAI_API_KEY": "local"}, + max_turns=1, + timeout_seconds=1, +) +result = NeMoWorker(worker).validate(Path("/tmp")) +if not result.success: + raise RuntimeError(result.error) +print(f"Avo NeMo preflight passed: {adapter_id}") PY COPY --from=ui-build /build/ui/dist /app/static diff --git a/README.md b/README.md index 6dcba65..5f85cd2 100644 --- a/README.md +++ b/README.md @@ -107,26 +107,114 @@ export AVO_APPLE_TEAM_ID=YOUR_APPLE_TEAM_ID ### Unified Docker image -The root `Dockerfile` builds the Expo web frontend and copies it into the Python runtime image, so frontend and backend deploy together: +The root `Dockerfile` builds the Expo web frontend and copies it into the Python runtime image, so frontend and backend deploy together. It also installs the pinned Hermes Agent source plus the matching NeMo Fabric Hermes adapter, then runs Avo's adapter/harness preflight during the image build. + +Build the image, then generate a container-valid configuration once. The repository must be mounted at the same path recorded in `avo.json`, and Avo state must live on a persistent mount so passkeys, conversations, runs, and worktree metadata survive container replacement: ```bash docker build -t avo-code . +mkdir -p /srv/avo + +docker run --rm \ + --entrypoint avo-harness \ + -v /srv/avo:/data \ + -v /path/to/repository:/workspace \ + avo-code \ + init --repo /workspace --state-dir /data/state --output /data/avo.json +``` + +The generated worker uses an OpenAI-compatible endpoint at `http://127.0.0.1:8000/v1`. Inside Docker, `127.0.0.1` means the Avo container itself. If the model server runs elsewhere, edit `/srv/avo/avo.json` so `worker.base_url` is reachable **from inside the Avo container**, for example a Compose service name, `host.docker.internal` where supported, or another routable host address. + +Hermes also requires the provider's API-key environment variable to be nonempty. For the default `provider: "openai"`, that is `OPENAI_API_KEY`. An unauthenticated local OpenAI-compatible endpoint may use a dummy nonempty value such as `local`; a provider that authenticates requests needs the real credential. + +Validate the final runtime configuration before starting the service: + +```bash +docker run --rm \ + --entrypoint avo-harness \ + -v /srv/avo:/data \ + -v /path/to/repository:/workspace \ + -e OPENAI_API_KEY=local \ + avo-code \ + doctor -c /data/avo.json +``` + +Then run Avo with the same repository mounts, model credentials, and network reachability: + +```bash docker run --rm \ -p 8765:8765 \ -v /srv/avo:/data \ + -v /path/to/repository:/workspace \ + -e OPENAI_API_KEY=local \ -e AVO_PUBLIC_ORIGIN=https://avo.penginlab.com \ -e AVO_APPLE_TEAM_ID=YOUR_APPLE_TEAM_ID \ avo-code ``` -The default container command expects `/data/avo.json` and serves on port `8765`. +The default container command expects `/data/avo.json` and serves on port `8765`. Mount any additional repository paths referenced by `.avo/access.yaml` as well. Do not point `state_dir` at the container's home directory for a replaceable deployment; use `/data/state` or another persisted path. ### Local development -Backend: +The generated default configuration uses NeMo Fabric's Hermes adapter. A usable local installation therefore needs **Python 3.11–3.13**, Hermes Agent 0.20+ installed from source, and the `nemo` extra. Installing only `.[web]` is not enough. + +The supported local setup is: + +```bash +python3.12 -m venv .venv +source .venv/bin/activate + +# Installs the same pinned Hermes source ref used by Docker and CI. +python scripts/install_hermes.py +python -m pip install -e '.[web,nemo,dev]' +python -m pip check + +avo-harness init --repo /path/to/repository --output avo.json + +# For the generated OpenAI-compatible local worker. Use the real key instead +# when the configured endpoint authenticates requests. +export OPENAI_API_KEY=local +avo-harness doctor -c avo.json +``` + +`avo-harness doctor` does not merely import `nemo_fabric`. It resolves every configured NeMo adapter, runs NeMo Fabric's diagnostics without calling the model, and checks Hermes' required credential environment. An environment with `available adapters: []`, a missing Hermes harness, an incompatible adapter configuration, a missing provider key, or a broken `ADAPTER_PYTHON` environment fails here instead of at the first coding request. Advisory NeMo diagnostics are printed as `WARN`; actual failed checks make `doctor` fail. + +By default Avo explicitly tells NeMo Fabric to launch adapter hosts with the same Python interpreter that is running Avo. This matters for virtualenv installs: relying on `python` from `PATH` can make discovery succeed in the Avo environment while the real adapter subprocess starts under system Python and fails to import `nemo_fabric_adapters`. + +Hermes 0.20.x deliberately refuses wheel/sdist builds. `python scripts/install_hermes.py` therefore performs Hermes' supported editable source install into the interpreter used to invoke it. The default checkout is `.deps/hermes-agent`; **that checkout is a runtime dependency and must not be deleted while the environment uses it**. Docker keeps the corresponding checkout under `/opt/hermes-agent`. Set `HERMES_AGENT_REF` only when intentionally testing a different Hermes release. + +If Hermes or another harness needs a separate dependency environment, keep Avo's main environment on the Fabric runtime and put the harness plus matching adapter in a second virtualenv: + +```bash +# Avo environment: Fabric runtime, web app, and Avo itself. +python3.12 -m venv .venv +.venv/bin/python -m pip install -e '.[web,fabric,dev]' + +# Hermes adapter environment: adapter package plus the pinned Hermes source. +python3.12 -m venv .venv-hermes +.venv-hermes/bin/python -m pip install 'nemo-fabric[hermes-agent]==0.2.0' +.venv-hermes/bin/python scripts/install_hermes.py .deps/hermes-agent-isolated +``` + +Then set the relevant worker in `avo.json` to the second interpreter while leaving model credentials alongside it or in Avo's process environment: + +```json +{ + "backend": "nemo", + "adapter_id": "nvidia.fabric.hermes", + "env": { + "ADAPTER_PYTHON": "/absolute/path/to/.venv-hermes/bin/python", + "OPENAI_API_KEY": "local" + } +} +``` + +Avo's CI executes a real Hermes turn using exactly this split-environment arrangement, so this is a tested deployment path rather than a fallback guess. + +Start the backend after `doctor` passes: ```bash -python -m pip install -e '.[web]' +OPENAI_API_KEY=local \ AVO_PUBLIC_ORIGIN=http://localhost:8765 \ AVO_AUTH_DISABLED=1 \ avo-harness web -c avo.json --host 0.0.0.0 --port 8765 diff --git a/pyproject.toml b/pyproject.toml index 131529f..18d7d1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,20 +7,21 @@ name = "avo-harness" version = "0.4.0" description = "Unofficial long-horizon agent orchestration inspired by NVIDIA AVO" readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.11,<3.14" authors = [{name = "AVO Harness contributors"}] dependencies = ["PyYAML>=6,<7"] [project.optional-dependencies] -# `nemo-fabric` by itself installs only the runtime. The default Avo worker is -# Hermes, so the base NeMo extra must also install Hermes' adapter descriptor. -# Hermes Agent 0.20+ itself is source-installed by the production image (or must -# already be present in the environment for local installs). -nemo = ["nemo-fabric[hermes-agent]>=0.2,<0.3"] -deepagents = ["nemo-fabric[deepagents]>=0.2,<0.3"] -codex = ["nemo-fabric[codex]>=0.2,<0.3"] -claude = ["nemo-fabric[claude]>=0.2,<0.3"] -mini-swe-agent = ["nemo-fabric[mini-swe-agent]>=0.2,<0.3"] +# NeMo Fabric's adapter packages are version-coupled to the runtime. Keep every +# adapter extra on the exact runtime release exercised by clean-install CI rather +# than letting a future 0.2.x publication silently change production behavior. +# Hermes Agent 0.20+ itself is source-installed by scripts/install_hermes.py. +fabric = ["nemo-fabric==0.2.0"] +nemo = ["nemo-fabric[hermes-agent]==0.2.0"] +deepagents = ["nemo-fabric[deepagents]==0.2.0"] +codex = ["nemo-fabric[codex]==0.2.0"] +claude = ["nemo-fabric[claude]==0.2.0"] +mini-swe-agent = ["nemo-fabric[mini-swe-agent]==0.2.0"] web = ["fastapi>=0.115,<1", "uvicorn[standard]>=0.34,<1", "webauthn>=3,<4"] dev = ["pytest>=8", "fastapi>=0.115,<1", "httpx>=0.28,<1", "webauthn>=3,<4"] diff --git a/scripts/install_hermes.py b/scripts/install_hermes.py new file mode 100644 index 0000000..a5a8c3e --- /dev/null +++ b/scripts/install_hermes.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Install the pinned Hermes Agent source into the current Python environment. + +Hermes Agent 0.20+ intentionally does not support wheel/sdist builds. Keep this +helper as Avo's single installation path so local development, CI, and Docker all +use the same source ref, interpreter, and durable editable checkout. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +HERMES_REPOSITORY = "https://github.com/NousResearch/hermes-agent.git" +DEFAULT_HERMES_REF = "v2026.8.19" + + +def run(*args: str) -> None: + subprocess.run(args, check=True) + + +def require_supported_python() -> None: + if (3, 11) <= sys.version_info[:2] < (3, 14): + return + raise SystemExit( + "Hermes Agent requires Python 3.11 through 3.13; " + f"this interpreter is Python {sys.version_info.major}.{sys.version_info.minor}." + ) + + +def checkout(destination: Path, ref: str) -> None: + if destination.exists(): + if not (destination / ".git").is_dir(): + raise SystemExit( + f"refusing to replace non-git path used for Hermes Agent: {destination}" + ) + origin = subprocess.run( + ("git", "-C", str(destination), "remote", "get-url", "origin"), + check=True, + capture_output=True, + text=True, + ).stdout.strip() + if origin != HERMES_REPOSITORY: + raise SystemExit( + f"refusing to reuse Hermes Agent checkout with unexpected origin: " + f"{destination} tracks {origin!r}, expected {HERMES_REPOSITORY!r}" + ) + run("git", "-C", str(destination), "fetch", "--depth", "1", "origin", ref) + run("git", "-C", str(destination), "checkout", "--detach", "FETCH_HEAD") + return + + destination.parent.mkdir(parents=True, exist_ok=True) + run( + "git", + "clone", + "--depth", + "1", + "--branch", + ref, + HERMES_REPOSITORY, + str(destination), + ) + + +def main() -> None: + require_supported_python() + root = Path(__file__).resolve().parents[1] + destination = ( + Path(sys.argv[1]).expanduser().resolve() + if len(sys.argv) > 1 + else root / ".deps" / "hermes-agent" + ) + ref = os.environ.get("HERMES_AGENT_REF", DEFAULT_HERMES_REF).strip() + if not ref: + raise SystemExit("HERMES_AGENT_REF cannot be empty") + + checkout(destination, ref) + # Hermes 0.20.x deliberately refuses wheel/sdist builds and supports source + # installation through an editable checkout. This checkout is therefore a + # runtime dependency: Docker keeps it under /opt, and local installs keep it + # under .deps unless the caller provides another durable destination. + run(sys.executable, "-m", "pip", "install", "-e", str(destination)) + + # Fail immediately if the source checkout did not install the expected + # distribution into this exact interpreter. + run( + sys.executable, + "-c", + ( + "from importlib.metadata import version; " + "v=version('hermes-agent'); " + "print(f'Hermes Agent installed: {v}')" + ), + ) + print(f"Hermes Agent source checkout (required at runtime): {destination}") + + +if __name__ == "__main__": + main() diff --git a/src/avo_harness/cli.py b/src/avo_harness/cli.py index 0be4486..8f04709 100644 --- a/src/avo_harness/cli.py +++ b/src/avo_harness/cli.py @@ -12,6 +12,7 @@ from .gitops import GitRepo from .orchestrator import Orchestrator from .store import Store +from .worker import NeMoWorker def _load(path: str) -> AVOConfig: @@ -23,21 +24,69 @@ def cmd_init(args: argparse.Namespace) -> int: if path.exists() and not args.force: print(f"refusing to overwrite {path}; use --force", file=sys.stderr) return 2 - path.write_text(json.dumps(example_config(args.repo), indent=2) + "\n", encoding="utf-8") + config = example_config(args.repo) + if args.state_dir is not None: + config["state_dir"] = args.state_dir + path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") print(path) return 0 -def _check_worker(label: str, worker: WorkerConfig, problems: list[str]) -> None: +def _check_worker( + label: str, + worker: WorkerConfig, + workspace: Path, + problems: list[str], + warnings: list[str], +) -> None: if worker.backend == "command": executable = worker.command[0] if "{" not in executable and shutil.which(executable) is None: problems.append(f"{label} executable not found: {executable}") - else: - try: - import nemo_fabric # noqa: F401 - except ImportError: - problems.append(f"{label}: nemo_fabric is not installed; install avo-harness[nemo]") + return + + # Importing nemo_fabric alone is not a usable-runtime check. The runtime can + # import while having zero discoverable adapter descriptors, which is exactly + # the failure mode that produces "available adapters: []" at execution time. + # NeMoWorker.validate resolves the configured adapter and runs Fabric.doctor + # without contacting the model. + result = NeMoWorker(worker).validate(workspace) + if not result.success: + problems.append(f"{label}: {result.error}") + return + warnings.extend( + f"{label}: {warning}" + for warning in result.metadata.get("doctor_warnings", []) + ) + + +def _configured_workers(config: AVOConfig) -> list[tuple[str, WorkerConfig]]: + workers: list[tuple[str, WorkerConfig]] = [("worker", config.worker)] + if config.planner.enabled and config.planner.worker is not None: + workers.append(("planner", config.planner.worker)) + if config.supervisor.enabled and config.supervisor.worker is not None: + workers.append(("supervisor", config.supervisor.worker)) + if config.team.enabled: + workers.extend( + (f"team.roles.{name}", worker) + for name, worker in config.team.resolved_workers(config.worker).items() + ) + return workers + + +def _check_configured_workers(config: AVOConfig) -> tuple[list[str], list[str]]: + problems: list[str] = [] + warnings: list[str] = [] + for label, worker in _configured_workers(config): + _check_worker(label, worker, config.repo_path, problems, warnings) + return list(dict.fromkeys(problems)), list(dict.fromkeys(warnings)) + + +def _print_worker_diagnostics(problems: list[str], warnings: list[str]) -> None: + for item in problems: + print(f"FAIL: {item}") + for item in warnings: + print(f"WARN: {item}") def cmd_doctor(args: argparse.Namespace) -> int: @@ -53,24 +102,13 @@ def cmd_doctor(args: argparse.Namespace) -> int: except Exception as exc: problems.append(str(exc)) - workers: list[tuple[str, WorkerConfig]] = [("worker", config.worker)] - if config.planner.enabled and config.planner.worker is not None: - workers.append(("planner", config.planner.worker)) - if config.supervisor.enabled and config.supervisor.worker is not None: - workers.append(("supervisor", config.supervisor.worker)) - if config.team.enabled: - workers.extend( - (f"team.roles.{name}", worker) - for name, worker in config.team.resolved_workers(config.worker).items() - ) - for label, worker in workers: - _check_worker(label, worker, problems) - + worker_problems, warnings = _check_configured_workers(config) + problems.extend(worker_problems) + problems = list(dict.fromkeys(problems)) + _print_worker_diagnostics(problems, warnings) if problems: - for item in dict.fromkeys(problems): - print(f"FAIL: {item}") return 1 - print("OK: configuration and runtime prerequisites look usable") + print("OK: configuration, adapters, harnesses, and runtime prerequisites look usable") return 0 @@ -125,6 +163,17 @@ def cmd_web(args: argparse.Namespace) -> int: return 2 from .api_chat import create_app + # Fail before binding a port if the configured worker stack cannot actually + # resolve its adapter/harness or lacks Hermes' required credential variable. + # This intentionally does not call the model endpoint. Connectivity remains + # an execution-time concern, but install/runtime mistakes should never hide + # behind a web server that appears healthy until its first chat request. + config = _load(args.config) + problems, warnings = _check_configured_workers(config) + _print_worker_diagnostics(problems, warnings) + if problems: + return 2 + uvicorn.run(create_app(args.config), host=args.host, port=args.port, log_level=args.log_level) return 0 @@ -154,6 +203,10 @@ def build_parser() -> argparse.ArgumentParser: init = sub.add_parser("init", help="write an example JSON configuration") init.add_argument("--repo", default=".") init.add_argument("--output", default="avo.json") + init.add_argument( + "--state-dir", + help="persistent state directory written into the generated configuration", + ) init.add_argument("--force", action="store_true") init.set_defaults(func=cmd_init) diff --git a/src/avo_harness/gitops.py b/src/avo_harness/gitops.py index fdb9b6d..00c58fb 100644 --- a/src/avo_harness/gitops.py +++ b/src/avo_harness/gitops.py @@ -1,21 +1,75 @@ from __future__ import annotations +import os import shutil import subprocess +import tempfile +from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path +from typing import Iterator class GitError(RuntimeError): pass -def _git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: - proc = subprocess.run( - ["git", "-C", str(repo), *args], - text=True, - capture_output=True, +def _git_config_value(value: str) -> str: + """Quote a value for Git's config-file syntax.""" + + escaped = ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\t", "\\t") ) + return f'"{escaped}"' + + +@contextmanager +def _trusted_repo_env(repo: Path) -> Iterator[dict[str, str]]: + """Give one Git subprocess a protected exact-path safe.directory entry. + + Host bind mounts commonly appear to be owned by a different UID inside a + container, which makes modern Git reject them before Avo can even inspect the + repository. Writing `safe.directory=*` would disable that protection globally. + Instead, each Avo Git command receives a short-lived global config containing + only the exact path it was asked to operate on. The user's normal environment + remains untouched after the subprocess exits. + """ + + trusted = repo.resolve() + env = os.environ.copy() + for key in ( + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_COMMON_DIR", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_PREFIX", + ): + env.pop(key, None) + with tempfile.TemporaryDirectory(prefix="avo-git-config-") as directory: + config = Path(directory) / "gitconfig" + config.write_text( + "[safe]\n" + f"\tdirectory = {_git_config_value(str(trusted))}\n", + encoding="utf-8", + ) + env["GIT_CONFIG_GLOBAL"] = str(config) + yield env + + +def _git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + repo = repo.resolve() + with _trusted_repo_env(repo) as env: + proc = subprocess.run( + ["git", "-C", str(repo), *args], + text=True, + capture_output=True, + env=env, + ) if check and proc.returncode != 0: raise GitError(proc.stderr.strip() or proc.stdout.strip() or "git command failed") return proc @@ -70,24 +124,16 @@ def commit_all(self, worktree: Worktree, message: str) -> str: if not status: return _git(worktree.path, "rev-parse", "HEAD").stdout.strip() _git(worktree.path, "add", "-A") - proc = subprocess.run( - [ - "git", - "-C", - str(worktree.path), - "-c", - "user.name=AVO Harness", - "-c", - "user.email=avo-harness@localhost", - "commit", - "-m", - message, - ], - text=True, - capture_output=True, + _git( + worktree.path, + "-c", + "user.name=AVO Harness", + "-c", + "user.email=avo-harness@localhost", + "commit", + "-m", + message, ) - if proc.returncode != 0: - raise GitError(proc.stderr.strip() or proc.stdout.strip()) return _git(worktree.path, "rev-parse", "HEAD").stdout.strip() def remove_worktree(self, worktree: Worktree) -> None: diff --git a/src/avo_harness/worker.py b/src/avo_harness/worker.py index 6d0afb9..d643145 100644 --- a/src/avo_harness/worker.py +++ b/src/avo_harness/worker.py @@ -4,6 +4,7 @@ import os import shlex import subprocess +import sys import tempfile import threading import time @@ -21,7 +22,10 @@ def run(self, prompt: str, workspace: Path) -> WorkerResult: ... _ADAPTER_ENV_LOCK = threading.Lock() -_MAX_TURNS_UNSUPPORTED_ADAPTERS = {"nvidia.fabric.codex"} +_MAX_TURNS_UNSUPPORTED_ADAPTERS = { + "nvidia.fabric.codex", + "nvidia.fabric.langchain.deepagents", +} _ADAPTER_INSTALL_HINTS = { "nvidia.fabric.hermes": "nemo-fabric[hermes-agent] plus Hermes Agent 0.20+ from source", "nvidia.fabric.codex": "nemo-fabric[codex]", @@ -29,6 +33,12 @@ def run(self, prompt: str, workspace: Path) -> WorkerResult: ... "nvidia.fabric.langchain.deepagents": "nemo-fabric[deepagents]", "nvidia.fabric.mini-swe-agent": "nemo-fabric[mini-swe-agent]", } +_HERMES_PROVIDER_API_KEY_ENV = { + "anthropic": "ANTHROPIC_API_KEY", + "nvidia": "NVIDIA_API_KEY", + "openai": "OPENAI_API_KEY", + "openrouter": "OPENROUTER_API_KEY", +} def _jsonable(value: Any) -> Any: @@ -61,13 +71,10 @@ def _normalized_usage(value: Any) -> tuple[int | None, int | None, float | None, if not isinstance(raw, dict): return None, None, None, raw if raw is not None else None - # NeMo adapters/providers do not all use the same field names. Prefer the - # normalized names, then accept common OpenAI/LangChain aliases. input_tokens = _number(raw, "input_tokens", "prompt_tokens", "input") output_tokens = _number(raw, "output_tokens", "completion_tokens", "output") cost = _number(raw, "cost_usd", "total_cost_usd", "cost") - # Some providers wrap usage in a nested token_usage/usage object. if input_tokens is None or output_tokens is None or cost is None: for key in ("token_usage", "usage", "tokens"): nested = raw.get(key) @@ -93,30 +100,40 @@ def _normalized_usage(value: Any) -> tuple[int | None, int | None, float | None, @contextmanager def _adapter_python_env(adapter_python: str | None): - """Temporarily select an isolated NeMo adapter environment for one worker. - - NeMo Fabric discovers Python adapter descriptors and their harness through - ADAPTER_PYTHON. WorkerConfig.env is already overrideable per role/strategy, - so treating that one key as runtime-owned lets Avo mix incompatible harness - environments without introducing another configuration surface. + """Temporarily select the Python interpreter NeMo uses for adapter hosts. + + NeMo Fabric discovers Python adapter descriptors and launches their harnesses + through ADAPTER_PYTHON. WorkerConfig.env can override that interpreter for a + role that needs an isolated environment. Otherwise Avo must explicitly use + its own sys.executable: relying on PATH can make descriptor discovery happen + in Avo's virtualenv while the persistent adapter host launches from a system + Python that cannot import the adapter package. + + ADAPTER_PYTHON lives in process-global os.environ, so every NeMo call must + participate in the same lock. Otherwise a worker without an override can run + while another worker has temporarily set ADAPTER_PYTHON and silently discover + the wrong adapter environment. """ - if not adapter_python: - yield - return - - # os.environ is process-global. Serialize only calls that override the - # interpreter so parallel workers cannot discover each other's adapters. with _ADAPTER_ENV_LOCK: previous = os.environ.get("ADAPTER_PYTHON") - os.environ["ADAPTER_PYTHON"] = adapter_python + if adapter_python: + os.environ["ADAPTER_PYTHON"] = adapter_python try: yield finally: - if previous is None: - os.environ.pop("ADAPTER_PYTHON", None) - else: - os.environ["ADAPTER_PYTHON"] = previous + if adapter_python: + if previous is None: + os.environ.pop("ADAPTER_PYTHON", None) + else: + os.environ["ADAPTER_PYTHON"] = previous + + +def _effective_adapter_python(config: WorkerConfig) -> str: + """Return the interpreter Fabric should use to discover and launch adapters.""" + + configured = config.env.get("ADAPTER_PYTHON", "").strip() + return configured or sys.executable def _nemo_failure(exc: Exception, adapter_id: str) -> str: @@ -133,6 +150,36 @@ def _nemo_failure(exc: Exception, adapter_id: str) -> str: return f"NeMo Fabric worker failed: {detail}" +def _hermes_api_key_env(config: WorkerConfig) -> str | None: + if config.adapter_id != "nvidia.fabric.hermes": + return None + if config.api_key_env: + return config.api_key_env + provider = str(config.provider or "").strip().lower() + api_key_env = _HERMES_PROVIDER_API_KEY_ENV.get(provider) + if api_key_env is None: + raise RuntimeError( + "Hermes requires worker.api_key_env for provider " + f"{provider or ''!r}; Avo cannot infer the credential environment variable." + ) + return api_key_env + + +def _validate_hermes_credentials(config: WorkerConfig) -> str | None: + api_key_env = _hermes_api_key_env(config) + if api_key_env is None: + return None + value = config.env.get(api_key_env) or os.environ.get(api_key_env) + if value: + return api_key_env + raise RuntimeError( + f"Hermes requires {api_key_env} to be nonempty at runtime. Set it in the Avo process " + f"environment or worker.env. The configured provider is {config.provider!r}. " + "For an unauthenticated local OpenAI-compatible endpoint, a dummy value such as " + "'local' is sufficient; do not use a dummy value for a provider that authenticates requests." + ) + + class CommandWorker: def __init__(self, config: WorkerConfig): self.config = config @@ -201,48 +248,63 @@ def __init__(self, config: WorkerConfig): def run(self, prompt: str, workspace: Path) -> WorkerResult: started = time.monotonic() - adapter_python = self.config.env.get("ADAPTER_PYTHON") + adapter_python = _effective_adapter_python(self.config) try: with _adapter_python_env(adapter_python): result = asyncio.run(self._run(prompt, workspace)) result.duration_seconds = time.monotonic() - started - if adapter_python: - result.metadata["adapter_python"] = adapter_python + result.metadata["adapter_python"] = adapter_python return result except Exception as exc: - metadata: dict[str, Any] = { - "backend": "nemo", - "adapter_id": self.config.adapter_id, - } - if adapter_python: - metadata["adapter_python"] = adapter_python - return WorkerResult( - success=False, - error=_nemo_failure(exc, self.config.adapter_id), - metadata=metadata, - duration_seconds=time.monotonic() - started, - ) + return self._failure_result(exc, started, adapter_python) - async def _run(self, prompt: str, workspace: Path) -> WorkerResult: + def validate(self, workspace: Path) -> WorkerResult: + """Resolve and diagnose the configured adapter without calling a model.""" + + started = time.monotonic() + adapter_python = _effective_adapter_python(self.config) try: - from nemo_fabric import Fabric, FabricConfig + with _adapter_python_env(adapter_python): + result = asyncio.run(self._validate(workspace)) + result.duration_seconds = time.monotonic() - started + result.metadata["adapter_python"] = adapter_python + return result + except Exception as exc: + return self._failure_result(exc, started, adapter_python) + + def _failure_result( + self, + exc: Exception, + started: float, + adapter_python: str | None, + ) -> WorkerResult: + metadata: dict[str, Any] = { + "backend": "nemo", + "adapter_id": self.config.adapter_id, + } + if adapter_python: + metadata["adapter_python"] = adapter_python + return WorkerResult( + success=False, + error=_nemo_failure(exc, self.config.adapter_id), + metadata=metadata, + duration_seconds=time.monotonic() - started, + ) + + def _fabric_config(self, workspace: Path): + try: + from nemo_fabric import FabricConfig except ImportError as exc: raise RuntimeError( "NeMo Fabric is not installed. Install this project with the 'nemo' extra." ) from exc - # ADAPTER_PYTHON selects the adapter host interpreter and must be visible - # to the Fabric runtime process, not forwarded as a harness environment - # variable. Other configured variables remain harness-visible. harness_env = dict(self.config.env) harness_env.pop("ADAPTER_PYTHON", None) runtime: dict[str, Any] = { "timeout_seconds": self.config.timeout_seconds, } - # max_turns is a normalized optional capability, not a universal one. - # Codex intentionally has no mapping for it; including Avo's default of - # 24 makes Fabric reject an otherwise valid Codex configuration. if self.config.adapter_id not in _MAX_TURNS_UNSUPPORTED_ADAPTERS: runtime["max_turns"] = self.config.max_turns @@ -250,6 +312,7 @@ async def _run(self, prompt: str, workspace: Path) -> WorkerResult: "metadata": {"name": "avo-worker"}, "harness": { "adapter_id": self.config.adapter_id, + "resolution": "preinstalled", "settings": self.config.harness_settings, }, "instructions": { @@ -287,11 +350,67 @@ async def _run(self, prompt: str, workspace: Path) -> WorkerResult: payload["telemetry"] = self.config.telemetry if hasattr(FabricConfig, "from_mapping"): - config = FabricConfig.from_mapping(payload) - else: - config = FabricConfig(**payload) + return FabricConfig.from_mapping(payload) + return FabricConfig(**payload) + + async def _validate(self, workspace: Path) -> WorkerResult: + try: + from nemo_fabric import Fabric + except ImportError as exc: + raise RuntimeError( + "NeMo Fabric is not installed. Install this project with the 'nemo' extra." + ) from exc + + config = self._fabric_config(workspace) + fabric = Fabric() + plan = fabric.plan(config, base_dir=workspace) + resolved_adapter = getattr(getattr(plan, "adapter", None), "adapter_id", None) + if resolved_adapter != self.config.adapter_id: + raise RuntimeError( + f"NeMo Fabric planned adapter {resolved_adapter!r}, expected {self.config.adapter_id!r}" + ) + + report = await fabric.doctor(config, base_dir=workspace) + doctor_status = str(getattr(report, "status", "unknown")) + failures = [] + warnings = [] + for check in getattr(report, "checks", []) or []: + status = str(getattr(check, "status", "")) + name = str(getattr(check, "name", "runtime")) + message = str(getattr(check, "message", "")) + detail = f"{name}: {message}" if message else name + if status == "fail": + failures.append(detail) + elif status == "warn": + warnings.append(detail) + if doctor_status == "fail" or failures: + detail = "; ".join(failures) or "overall status fail" + raise RuntimeError(f"NeMo Fabric doctor failed: {detail}") + if doctor_status not in {"pass", "warn"}: + raise RuntimeError(f"NeMo Fabric doctor returned unknown status {doctor_status!r}") + + api_key_env = _validate_hermes_credentials(self.config) + metadata: dict[str, Any] = { + "backend": "nemo", + "adapter_id": self.config.adapter_id, + "doctor_status": doctor_status, + } + if api_key_env: + metadata["api_key_env"] = api_key_env + if warnings: + metadata["doctor_warnings"] = warnings + return WorkerResult(success=True, metadata=metadata) + + async def _run(self, prompt: str, workspace: Path) -> WorkerResult: + try: + from nemo_fabric import Fabric + except ImportError as exc: + raise RuntimeError( + "NeMo Fabric is not installed. Install this project with the 'nemo' extra." + ) from exc - result = await Fabric().run(config, input=prompt) + config = self._fabric_config(workspace) + result = await Fabric().run(config, base_dir=workspace, input=prompt) output_obj = getattr(result, "output", None) response = getattr(output_obj, "response", "") if output_obj is not None else "" error_obj = getattr(result, "error", None) diff --git a/tests/support/openai_smoke_server.py b/tests/support/openai_smoke_server.py new file mode 100644 index 0000000..a4d034a --- /dev/null +++ b/tests/support/openai_smoke_server.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Tiny OpenAI-compatible server used only by the clean-install CI smoke test.""" + +from __future__ import annotations + +import json +import sys +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +SMOKE_TEXT = "AVO_HERMES_SMOKE_OK" +MODEL = "local-open-model" + + +class Handler(BaseHTTPRequestHandler): + server_version = "AvoOpenAISmoke/1.0" + + def log_message(self, format: str, *args) -> None: + sys.stderr.write("smoke-server: " + format % args + "\n") + + def _json(self, status: int, payload: object) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: + if self.path.rstrip("/") in {"/v1/models", "/models"}: + self._json( + 200, + { + "object": "list", + "data": [ + { + "id": MODEL, + "object": "model", + "created": int(time.time()), + "owned_by": "avo-ci", + } + ], + }, + ) + return + if self.path.startswith("/v1/models/"): + self._json( + 200, + { + "id": MODEL, + "object": "model", + "created": int(time.time()), + "owned_by": "avo-ci", + }, + ) + return + self._json(404, {"error": {"message": f"unknown path {self.path}"}}) + + def do_POST(self) -> None: + if self.path.rstrip("/") not in {"/v1/chat/completions", "/chat/completions"}: + self._json(404, {"error": {"message": f"unknown path {self.path}"}}) + return + + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length) if length else b"{}" + try: + request = json.loads(raw) + except json.JSONDecodeError: + self._json(400, {"error": {"message": "invalid JSON"}}) + return + + model = str(request.get("model") or MODEL) + created = int(time.time()) + if request.get("stream"): + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "close") + self.end_headers() + chunks = [ + { + "id": "chatcmpl-avo-smoke", + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": SMOKE_TEXT}, + "finish_reason": None, + } + ], + }, + { + "id": "chatcmpl-avo-smoke", + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [ + {"index": 0, "delta": {}, "finish_reason": "stop"} + ], + "usage": { + "prompt_tokens": 5, + "completion_tokens": 4, + "total_tokens": 9, + }, + }, + ] + for chunk in chunks: + self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode("utf-8")) + self.wfile.flush() + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + return + + self._json( + 200, + { + "id": "chatcmpl-avo-smoke", + "object": "chat.completion", + "created": created, + "model": model, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": SMOKE_TEXT, + "refusal": None, + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 5, + "completion_tokens": 4, + "total_tokens": 9, + }, + }, + ) + + +def main() -> None: + port = int(sys.argv[1]) if len(sys.argv) > 1 else 18080 + ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever() + + +if __name__ == "__main__": + main() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..ab81746 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from avo_harness.cli import build_parser + + +def test_init_writes_explicit_state_directory(tmp_path: Path) -> None: + output = tmp_path / "avo.json" + state = tmp_path / "persistent-state" + parser = build_parser() + args = parser.parse_args( + [ + "init", + "--repo", + str(tmp_path), + "--state-dir", + str(state), + "--output", + str(output), + ] + ) + + assert args.func(args) == 0 + payload = json.loads(output.read_text(encoding="utf-8")) + assert payload["repo"] == str(tmp_path) + assert payload["state_dir"] == str(state) + assert payload["worker"]["adapter_id"] == "nvidia.fabric.hermes" diff --git a/tests/test_gitops.py b/tests/test_gitops.py new file mode 100644 index 0000000..b6b897e --- /dev/null +++ b/tests/test_gitops.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +from avo_harness.gitops import _git + + +def test_git_uses_ephemeral_exact_safe_directory(monkeypatch, tmp_path: Path) -> None: + repo = tmp_path / 'repo with spaces' + repo.mkdir() + observed: dict[str, str] = {} + + def fake_run(command, *, text, capture_output, env): + config_path = Path(env['GIT_CONFIG_GLOBAL']) + observed['config'] = config_path.read_text(encoding='utf-8') + observed['global_path'] = str(config_path) + observed['command'] = ' '.join(command) + assert config_path.exists() + return subprocess.CompletedProcess(command, 0, stdout='true\n', stderr='') + + monkeypatch.setattr(subprocess, 'run', fake_run) + original_global = os.environ.get('GIT_CONFIG_GLOBAL') + + result = _git(repo, 'rev-parse', '--is-inside-work-tree') + + assert result.stdout.strip() == 'true' + assert f'directory = "{repo.resolve()}"' in observed['config'] + assert 'directory = "*"' not in observed['config'] + assert observed['command'].startswith(f'git -C {repo.resolve()}') + assert not Path(observed['global_path']).exists() + assert os.environ.get('GIT_CONFIG_GLOBAL') == original_global + + +def test_git_strips_inherited_repository_location_variables(monkeypatch, tmp_path: Path) -> None: + repo = tmp_path / 'target' + repo.mkdir() + location_variables = ( + 'GIT_DIR', + 'GIT_WORK_TREE', + 'GIT_COMMON_DIR', + 'GIT_INDEX_FILE', + 'GIT_OBJECT_DIRECTORY', + 'GIT_ALTERNATE_OBJECT_DIRECTORIES', + 'GIT_PREFIX', + ) + for key in location_variables: + monkeypatch.setenv(key, f'/inherited/{key.lower()}') + + def fake_run(command, *, text, capture_output, env): + assert all(key not in env for key in location_variables) + return subprocess.CompletedProcess(command, 0, stdout='true\n', stderr='') + + monkeypatch.setattr(subprocess, 'run', fake_run) + + assert _git(repo, 'rev-parse', '--is-inside-work-tree').stdout.strip() == 'true' diff --git a/tests/test_install_hermes.py b/tests/test_install_hermes.py new file mode 100644 index 0000000..f10b3b2 --- /dev/null +++ b/tests/test_install_hermes.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import importlib.util +import subprocess +from pathlib import Path + +import pytest + + +def load_installer(): + path = Path(__file__).parents[1] / 'scripts' / 'install_hermes.py' + spec = importlib.util.spec_from_file_location('install_hermes', path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_checkout_rejects_existing_repository_with_wrong_origin(monkeypatch, tmp_path: Path) -> None: + installer = load_installer() + destination = tmp_path / 'hermes-agent' + (destination / '.git').mkdir(parents=True) + commands: list[tuple[str, ...]] = [] + + def fake_run(command, **kwargs): + command = tuple(command) + commands.append(command) + return subprocess.CompletedProcess(command, 0, stdout='https://example.invalid/not-hermes.git\n') + + monkeypatch.setattr(subprocess, 'run', fake_run) + + with pytest.raises(SystemExit, match='unexpected origin'): + installer.checkout(destination, installer.DEFAULT_HERMES_REF) + + assert commands == [('git', '-C', str(destination), 'remote', 'get-url', 'origin')] + + +def test_checkout_fetches_existing_repository_with_expected_origin(monkeypatch, tmp_path: Path) -> None: + installer = load_installer() + destination = tmp_path / 'hermes-agent' + (destination / '.git').mkdir(parents=True) + commands: list[tuple[str, ...]] = [] + + def fake_run(command, **kwargs): + command = tuple(command) + commands.append(command) + stdout = installer.HERMES_REPOSITORY + '\n' if command[-3:] == ('remote', 'get-url', 'origin') else '' + return subprocess.CompletedProcess(command, 0, stdout=stdout) + + monkeypatch.setattr(subprocess, 'run', fake_run) + + installer.checkout(destination, installer.DEFAULT_HERMES_REF) + + assert commands == [ + ('git', '-C', str(destination), 'remote', 'get-url', 'origin'), + ('git', '-C', str(destination), 'fetch', '--depth', '1', 'origin', installer.DEFAULT_HERMES_REF), + ('git', '-C', str(destination), 'checkout', '--detach', 'FETCH_HEAD'), + ] diff --git a/tests/test_worker_capabilities.py b/tests/test_worker_capabilities.py new file mode 100644 index 0000000..65072ac --- /dev/null +++ b/tests/test_worker_capabilities.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from types import SimpleNamespace + +from avo_harness.config import WorkerConfig +from avo_harness.worker import NeMoWorker + + +class FakeFabricConfig: + @classmethod + def from_mapping(cls, payload): + return payload + + +def test_deepagents_omits_unsupported_max_turns(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setitem(sys.modules, "nemo_fabric", SimpleNamespace(FabricConfig=FakeFabricConfig)) + worker = NeMoWorker( + WorkerConfig( + backend="nemo", + adapter_id="nvidia.fabric.langchain.deepagents", + provider="openai", + model="smoke", + max_turns=37, + timeout_seconds=91, + ) + ) + + payload = worker._fabric_config(tmp_path) + + assert payload["runtime"] == {"timeout_seconds": 91} diff --git a/tests/test_worker_nemo.py b/tests/test_worker_nemo.py index 8ca59da..6711e06 100644 --- a/tests/test_worker_nemo.py +++ b/tests/test_worker_nemo.py @@ -3,11 +3,13 @@ import asyncio import os import sys +import threading +import time from pathlib import Path from types import SimpleNamespace from avo_harness.config import WorkerConfig -from avo_harness.worker import NeMoWorker, _nemo_failure +from avo_harness.worker import NeMoWorker, _adapter_python_env, _nemo_failure class FakeFabricConfig: @@ -18,10 +20,24 @@ def from_mapping(cls, payload): class FakeFabric: last_config = None + last_base_dir = None adapter_python_seen = None - async def run(self, config, *, input): + def plan(self, config, *, base_dir=None): type(self).last_config = config + type(self).last_base_dir = base_dir + return SimpleNamespace( + adapter=SimpleNamespace(adapter_id=config["harness"]["adapter_id"]) + ) + + async def doctor(self, config, *, base_dir=None): + type(self).last_config = config + type(self).last_base_dir = base_dir + return SimpleNamespace(status="pass", checks=[]) + + async def run(self, config, *, base_dir=None, input): + type(self).last_config = config + type(self).last_base_dir = base_dir type(self).adapter_python_seen = os.environ.get("ADAPTER_PYTHON") return SimpleNamespace( output=SimpleNamespace(response=f"done: {input}"), @@ -33,17 +49,51 @@ async def run(self, config, *, input): ) -def install_fake_fabric(monkeypatch) -> None: +class FailingDoctorFabric(FakeFabric): + async def doctor(self, config, *, base_dir=None): + type(self).last_config = config + type(self).last_base_dir = base_dir + return SimpleNamespace( + status="fail", + checks=[ + SimpleNamespace( + status="fail", + name="adapter.requirements", + message="Hermes Agent is not installed", + ) + ], + ) + + +class WarningDoctorFabric(FakeFabric): + async def doctor(self, config, *, base_dir=None): + type(self).last_config = config + type(self).last_base_dir = base_dir + return SimpleNamespace( + status="warn", + checks=[ + SimpleNamespace( + status="warn", + name="model.connectivity", + message="model endpoint was not contacted during preflight", + ) + ], + ) + + +def install_fake_fabric(monkeypatch, fabric_class=FakeFabric) -> None: FakeFabric.last_config = None + FakeFabric.last_base_dir = None FakeFabric.adapter_python_seen = None monkeypatch.setitem( sys.modules, "nemo_fabric", - SimpleNamespace(Fabric=FakeFabric, FabricConfig=FakeFabricConfig), + SimpleNamespace(Fabric=fabric_class, FabricConfig=FakeFabricConfig), ) def nemo_config(adapter_id: str, **kwargs) -> WorkerConfig: + kwargs.setdefault("env", {"OPENAI_API_KEY": "test"}) return WorkerConfig( backend="nemo", adapter_id=adapter_id, @@ -62,6 +112,7 @@ def test_codex_omits_unsupported_max_turns(monkeypatch, tmp_path: Path) -> None: assert result.success is True assert FakeFabric.last_config["runtime"] == {"timeout_seconds": 91} + assert FakeFabric.last_base_dir == tmp_path def test_hermes_keeps_max_turns(monkeypatch, tmp_path: Path) -> None: @@ -75,6 +126,94 @@ def test_hermes_keeps_max_turns(monkeypatch, tmp_path: Path) -> None: "timeout_seconds": 91, "max_turns": 37, } + assert FakeFabric.last_config["harness"]["resolution"] == "preinstalled" + assert FakeFabric.last_base_dir == tmp_path + + +def test_validate_resolves_adapter_and_runs_doctor(monkeypatch, tmp_path: Path) -> None: + install_fake_fabric(monkeypatch) + worker = NeMoWorker(nemo_config("nvidia.fabric.hermes")) + + result = worker.validate(tmp_path) + + assert result.success is True + assert result.metadata["adapter_id"] == "nvidia.fabric.hermes" + assert result.metadata["doctor_status"] == "pass" + assert result.metadata["api_key_env"] == "OPENAI_API_KEY" + assert result.metadata["adapter_python"] == sys.executable + assert FakeFabric.last_config["harness"]["adapter_id"] == "nvidia.fabric.hermes" + assert FakeFabric.last_config["harness"]["resolution"] == "preinstalled" + assert FakeFabric.last_base_dir == tmp_path + + +def test_validate_reports_doctor_failures(monkeypatch, tmp_path: Path) -> None: + install_fake_fabric(monkeypatch, FailingDoctorFabric) + worker = NeMoWorker(nemo_config("nvidia.fabric.hermes")) + + result = worker.validate(tmp_path) + + assert result.success is False + assert "adapter.requirements" in result.error + assert "Hermes Agent is not installed" in result.error + + +def test_validate_preserves_doctor_warnings_without_failing(monkeypatch, tmp_path: Path) -> None: + install_fake_fabric(monkeypatch, WarningDoctorFabric) + worker = NeMoWorker(nemo_config("nvidia.fabric.hermes")) + + result = worker.validate(tmp_path) + + assert result.success is True + assert result.metadata["doctor_status"] == "warn" + assert result.metadata["doctor_warnings"] == [ + "model.connectivity: model endpoint was not contacted during preflight" + ] + + +def test_validate_fails_before_first_turn_when_hermes_key_is_missing( + monkeypatch, + tmp_path: Path, +) -> None: + install_fake_fabric(monkeypatch) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + worker = NeMoWorker(nemo_config("nvidia.fabric.hermes", env={})) + + result = worker.validate(tmp_path) + + assert result.success is False + assert "OPENAI_API_KEY" in result.error + assert "dummy value" in result.error + + +def test_validate_accepts_explicit_hermes_api_key_env(monkeypatch, tmp_path: Path) -> None: + install_fake_fabric(monkeypatch) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + worker = NeMoWorker( + nemo_config( + "nvidia.fabric.hermes", + api_key_env="LOCAL_MODEL_TOKEN", + env={"LOCAL_MODEL_TOKEN": "local"}, + ) + ) + + result = worker.validate(tmp_path) + + assert result.success is True + assert result.metadata["api_key_env"] == "LOCAL_MODEL_TOKEN" + assert FakeFabric.last_config["models"]["default"]["api_key_env"] == "LOCAL_MODEL_TOKEN" + + +def test_nemo_defaults_adapter_host_to_its_running_interpreter(monkeypatch, tmp_path: Path) -> None: + install_fake_fabric(monkeypatch) + monkeypatch.delenv("ADAPTER_PYTHON", raising=False) + worker = NeMoWorker(nemo_config("nvidia.fabric.hermes")) + + result = worker.run("fix it", tmp_path) + + assert result.success is True + assert FakeFabric.adapter_python_seen == sys.executable + assert result.metadata["adapter_python"] == sys.executable + assert os.environ.get("ADAPTER_PYTHON") is None def test_adapter_python_is_scoped_to_one_worker(monkeypatch, tmp_path: Path) -> None: @@ -97,6 +236,41 @@ def test_adapter_python_is_scoped_to_one_worker(monkeypatch, tmp_path: Path) -> assert result.metadata["adapter_python"] == adapter_python +def test_adapter_python_override_cannot_leak_to_unscoped_worker(monkeypatch) -> None: + monkeypatch.delenv("ADAPTER_PYTHON", raising=False) + override_entered = threading.Event() + unscoped_attempted = threading.Event() + release_override = threading.Event() + observed: list[str | None] = [] + + def scoped_worker() -> None: + with _adapter_python_env("/tmp/hermes-python"): + override_entered.set() + assert release_override.wait(timeout=2) + + def unscoped_worker() -> None: + assert override_entered.wait(timeout=2) + unscoped_attempted.set() + with _adapter_python_env(None): + observed.append(os.environ.get("ADAPTER_PYTHON")) + + scoped = threading.Thread(target=scoped_worker) + unscoped = threading.Thread(target=unscoped_worker) + scoped.start() + unscoped.start() + + assert unscoped_attempted.wait(timeout=2) + time.sleep(0.05) + release_override.set() + scoped.join(timeout=2) + unscoped.join(timeout=2) + + assert not scoped.is_alive() + assert not unscoped.is_alive() + assert observed == [None] + assert os.environ.get("ADAPTER_PYTHON") is None + + def test_unknown_adapter_error_explains_install_and_isolation() -> None: message = _nemo_failure( RuntimeError("unknown adapter nvidia.fabric.hermes"), diff --git a/ui/package.json b/ui/package.json index 0c929c7..ad375be 100644 --- a/ui/package.json +++ b/ui/package.json @@ -11,19 +11,19 @@ "doctor": "expo-doctor" }, "dependencies": { - "@expo/vector-icons": "*", + "@expo/vector-icons": "15.1.1", "expo": "^57.0.17", "expo-build-properties": "~57.0.17", "expo-router": "~57.0.20", - "expo-secure-store": "*", - "expo-status-bar": "*", + "expo-secure-store": "57.0.4", + "expo-status-bar": "57.0.1", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.3", "react-native-passkeys": "^0.4.2", - "react-native-safe-area-context": "*", - "react-native-screens": "*", - "react-native-web": "0.21.0" + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.26.0", + "react-native-web": "^0.21.2" }, "devDependencies": { "@types/react": "~19.2.2",