diff --git a/.env.example b/.env.example index 283ccc6..473dae0 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,13 @@ OPENROUTER_API_KEY= # Optional: API key required when the control plane enforces auth AGENTFIELD_API_KEY= -# Optional model/provider overrides -HARNESS_PROVIDER=opencode +# AForge exec is the default. Set HARNESS_PROVIDER=opencode to roll back — +# OpenCode stays installed in the image. +HARNESS_PROVIDER=aforge +# Which aforge subcommand the SDK runs: `exec` (default, direct one-shot) or +# `do` (routed workflow). Honoured by agentfield >= 0.1.130. +AGENTFIELD_AFORGE_COMMAND=exec +# Override which aforge binary runs (honoured today via HarnessConfig.aforge_bin). +# CONTRACT_AF_AFORGE_BIN=/absolute/path/to/aforge HARNESS_MODEL=openrouter/moonshotai/kimi-k2.5 AI_MODEL=openrouter/moonshotai/kimi-k2.5 diff --git a/Dockerfile b/Dockerfile index 17b87d6..4d8890c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,40 @@ +# AForge CLI — fetched from the public release mirror and verified against the +# release checksums (which hash the *uncompressed* binaries). Both ARGs are +# overridable so CI or a local mirror can serve the same layout elsewhere: +# docker build --build-arg AFORGE_BASE_URL=... --build-arg AFORGE_VERSION=... . +ARG AFORGE_BASE_URL=https://agentfield.ai/downloads/aforge +ARG AFORGE_VERSION=v0.1.0 + +# Reuses the python:3.11-slim base (debian bookworm) already pulled for the +# builder/runtime stages rather than adding a second base image to the build. +FROM python:3.11-slim AS aforge + +ARG AFORGE_BASE_URL +ARG AFORGE_VERSION +# TARGETARCH is populated by BuildKit; dpkg is the fallback for the legacy +# builder, where the build platform is always the target platform. +ARG TARGETARCH + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /out + +RUN set -eu; \ + arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \ + curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/aforge-linux-${arch}.gz" -o aforge.gz; \ + gunzip -c aforge.gz > aforge; \ + rm aforge.gz; \ + curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/checksums.txt" -o checksums.txt; \ + grep " aforge-linux-${arch}$" checksums.txt \ + | sed "s/ aforge-linux-.*/ aforge/" \ + | sha256sum -c -; \ + rm checksums.txt; \ + chmod +x aforge + + FROM python:3.11-slim AS builder ENV PYTHONDONTWRITEBYTECODE=1 \ @@ -14,7 +51,7 @@ COPY pyproject.toml README.md ./ COPY src/ src/ RUN pip install --no-cache-dir --prefix=/install \ - "agentfield>=0.1.47" \ + "agentfield>=0.1.130" \ "pydantic>=2.0" \ "httpx>=0.27" \ "python-dotenv>=1.0" \ @@ -28,7 +65,8 @@ FROM python:3.11-slim AS runtime ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ AGENTFIELD_SERVER=http://agentfield:8080 \ - HARNESS_PROVIDER=opencode \ + HARNESS_PROVIDER=aforge \ + AGENTFIELD_AFORGE_COMMAND=exec \ HARNESS_MODEL=openrouter/moonshotai/kimi-k2.5 \ AI_MODEL=openrouter/moonshotai/kimi-k2.5 \ PORT=8004 \ @@ -54,6 +92,7 @@ RUN mkdir -p /home/contractaf/.config/opencode && \ chown -R contractaf:contractaf /home/contractaf/.config COPY --from=builder /install /usr/local +COPY --from=aforge /out/aforge /usr/local/bin/aforge COPY src/ /app/src/ USER contractaf diff --git a/README.md b/README.md index 16e0462..a4abe34 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,12 @@ docker compose up --build Starts AgentField control plane (`http://localhost:8080`) + Contract-AF agent. +AForge `exec` is the default coding harness. The image fetches the released +`aforge` binary from `https://agentfield.ai/downloads/aforge` at build time and +verifies it against the release checksums. OpenCode stays installed as the +configuration-only rollback: set `HARNESS_PROVIDER=opencode`. Point +`CONTRACT_AF_AFORGE_BIN` at another binary to override which `aforge` runs. + ```bash curl -X POST http://localhost:8080/api/v1/execute/async/contract-af.analyze \ -H "Content-Type: application/json" \ diff --git a/agentfield-package.yaml b/agentfield-package.yaml new file mode 100644 index 0000000..c249bb9 --- /dev/null +++ b/agentfield-package.yaml @@ -0,0 +1,58 @@ +config_version: v1 # manifest schema version (omit = legacy v0) +name: contract-af +version: 0.1.0 +description: AI-native legal contract risk analyzer — finds dangerous clauses, proves exploitability +author: Agent-Field + +entrypoint: + start: python -m contract_af.app + healthcheck: /health + +agent_node: + node_id: contract-af + default_port: 8004 + +dependencies: + system: + # `af install` builds a Python venv; it does not fetch harness CLIs (the + # Docker image does). Printed as a manual step so a local install says so + # up front instead of failing on the first harness call. + - "aforge (https://agentfield.ai/downloads/aforge) — or set HARNESS_PROVIDER=opencode" + +user_environment: + required: + # Drives both the coding harness (AForge/OpenCode) and the .ai() reasoning + # calls — every phase of the pipeline needs it. + - name: OPENROUTER_API_KEY + description: LLM provider key (OpenRouter) + type: secret + scope: global + optional: + - name: AGENTFIELD_SERVER + description: Control-plane URL + default: http://localhost:8080 + - name: AGENTFIELD_API_KEY + description: Control-plane API key (if auth is enabled) + type: secret + scope: global + - name: NODE_ID + description: Identity this node registers under + default: contract-af + # AForge is the default harness. OpenCode stays installed as the + # configuration-only rollback: set this to `opencode`. + - name: HARNESS_PROVIDER + description: Coding-agent harness provider (aforge | opencode) + default: aforge + - name: HARNESS_MODEL + description: Model the coding harness uses + default: openrouter/moonshotai/kimi-k2.5 + - name: AI_MODEL + description: Model the .ai() reasoning calls use + default: openrouter/moonshotai/kimi-k2.5 + - name: CONTRACT_AF_AFORGE_BIN + description: Path to the aforge binary (defaults to `aforge` on PATH) + - name: CONTRACT_AF_OPENCODE_BIN + description: Path to the opencode binary (defaults to `opencode` on PATH) + - name: CONTRACT_AF_MAX_TURNS + description: Maximum harness iterations per call + default: "50" diff --git a/docker-compose.yml b/docker-compose.yml index 0061a07..fb930d5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,8 @@ services: - AGENTFIELD_SERVER=http://agentfield:8080 - AGENTFIELD_API_KEY=${AGENTFIELD_API_KEY:-} - AGENT_CALLBACK_URL=http://contract-af:8004 - - HARNESS_PROVIDER=opencode + - HARNESS_PROVIDER=${HARNESS_PROVIDER:-aforge} + - AGENTFIELD_AFORGE_COMMAND=${AGENTFIELD_AFORGE_COMMAND:-exec} - HARNESS_MODEL=${HARNESS_MODEL:-openrouter/moonshotai/kimi-k2.5} - AI_MODEL=${AI_MODEL:-openrouter/moonshotai/kimi-k2.5} - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} diff --git a/pyproject.toml b/pyproject.toml index a8415cc..9bde38d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "0.1.0" description = "Legal contract risk analyzer — finds dangerous clauses, proves exploitability" requires-python = ">=3.10" dependencies = [ - "agentfield", + "agentfield>=0.1.130", "pydantic>=2.0", "fastapi>=0.100", "uvicorn>=0.20", diff --git a/src/contract_af/app.py b/src/contract_af/app.py index b6b1626..acbf007 100644 --- a/src/contract_af/app.py +++ b/src/contract_af/app.py @@ -44,6 +44,7 @@ max_turns=_ai_config.max_turns, env=_ai_config.provider_env(), opencode_bin=_ai_config.opencode_bin, + aforge_bin=_ai_config.aforge_bin, permission_mode="auto", ), ai_config=AIConfig( diff --git a/src/contract_af/config.py b/src/contract_af/config.py index 7a719e4..65a0dbc 100644 --- a/src/contract_af/config.py +++ b/src/contract_af/config.py @@ -9,7 +9,7 @@ class AIIntegrationConfig(BaseModel): provider: str = Field( default_factory=lambda: os.getenv( - "CONTRACT_AF_PROVIDER", os.getenv("HARNESS_PROVIDER", "opencode") + "CONTRACT_AF_PROVIDER", os.getenv("HARNESS_PROVIDER", "aforge") ) ) harness_model: str = Field( @@ -33,6 +33,11 @@ class AIIntegrationConfig(BaseModel): opencode_bin: str = Field( default_factory=lambda: os.getenv("CONTRACT_AF_OPENCODE_BIN", "opencode") ) + aforge_bin: str = Field( + default_factory=lambda: os.getenv( + "CONTRACT_AF_AFORGE_BIN", os.getenv("AFORGE_BIN", "aforge") + ) + ) opencode_server: str | None = Field( default_factory=lambda: os.getenv( "CONTRACT_AF_OPENCODE_SERVER", os.getenv("OPENCODE_SERVER") @@ -53,6 +58,9 @@ def provider_env(self) -> dict[str, str]: env: dict[str, str] = { key: value for key in env_keys if (value := os.getenv(key)) } + env["AGENTFIELD_AFORGE_COMMAND"] = os.getenv( + "AGENTFIELD_AFORGE_COMMAND", "exec" + ) xdg = os.getenv("XDG_DATA_HOME") or os.path.join( tempfile.gettempdir(), "opencode-shared-data" ) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..7f90617 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import pytest + +from contract_af.config import AIIntegrationConfig + + +def test_aforge_exec_is_the_default_harness(monkeypatch: pytest.MonkeyPatch) -> None: + for key in ( + "CONTRACT_AF_PROVIDER", + "HARNESS_PROVIDER", + "CONTRACT_AF_AFORGE_BIN", + "AFORGE_BIN", + "AGENTFIELD_AFORGE_COMMAND", + ): + monkeypatch.delenv(key, raising=False) + + config = AIIntegrationConfig.from_env() + + assert config.provider == "aforge" + assert config.aforge_bin == "aforge" + assert config.provider_env()["AGENTFIELD_AFORGE_COMMAND"] == "exec" + + +def test_opencode_remains_an_explicit_rollback(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HARNESS_PROVIDER", "opencode") + + assert AIIntegrationConfig.from_env().provider == "opencode" + + +def test_aforge_bin_override_precedence(monkeypatch: pytest.MonkeyPatch) -> None: + """CONTRACT_AF_AFORGE_BIN wins over AFORGE_BIN, which wins over the default.""" + monkeypatch.delenv("CONTRACT_AF_AFORGE_BIN", raising=False) + monkeypatch.setenv("AFORGE_BIN", "/opt/aforge/aforge") + + assert AIIntegrationConfig.from_env().aforge_bin == "/opt/aforge/aforge" + + monkeypatch.setenv("CONTRACT_AF_AFORGE_BIN", "/usr/local/bin/aforge") + + assert AIIntegrationConfig.from_env().aforge_bin == "/usr/local/bin/aforge" + + +def test_provider_env_forwards_provider_keys(monkeypatch: pytest.MonkeyPatch) -> None: + """Harness subprocess env carries the LLM key AForge needs to reach OpenRouter.""" + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-test-key") + + env = AIIntegrationConfig.from_env().provider_env() + + assert env["OPENROUTER_API_KEY"] == "sk-test-key"